diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index b0f42ab8..4dba1e5b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,17 +5,58 @@ on: branches: - main - develop + - feat/recipe pull_request: branches: - main - develop + - feat/recipe concurrency: group: e2e-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + recipe-docker-e2e: + name: Docker Recipe E2E + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Verify Docker is available + run: docker info + + - name: Run recipe docker e2e + env: + CLAWPAL_RUN_DOCKER_RECIPE_E2E: "1" + run: cargo test -p clawpal --test recipe_docker_e2e -- --nocapture --test-threads=1 + working-directory: src-tauri + profile-e2e: + name: Provider Auth E2E runs-on: ubuntu-latest environment: ${{ (github.base_ref == 'main' || github.ref == 'refs/heads/main') && 'production' || 'development' }} steps: diff --git a/Cargo.lock b/Cargo.lock index 3b1bff67..13db4c42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,10 +555,12 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "serde_yaml", "shell-words", "shellexpand", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-updater", "thiserror 1.0.69", @@ -1006,6 +1008,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", + "block2", + "libc", "objc2", ] @@ -3833,6 +3837,30 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4424,6 +4452,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4467,6 +4508,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -4991,6 +5038,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -5638,6 +5725,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -5696,6 +5789,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/README.md b/README.md index 79861709..bd876d9b 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,17 @@ src-tauri/ Rust + Tauri backend docs/plans/ Design and implementation plans ``` +## Recipe docs + +- [`docs/recipe-authoring.md`](docs/recipe-authoring.md) — how to write and package a ClawPal recipe +- [`docs/recipe-cli-action-catalog.md`](docs/recipe-cli-action-catalog.md) — full CLI-backed recipe action catalog and support matrix +- [`docs/recipe-runner-boundaries.md`](docs/recipe-runner-boundaries.md) — runner/backend boundaries and OpenClaw-first design rules + +## Testing docs + +- [`docs/testing/business-flow-test-matrix.md`](docs/testing/business-flow-test-matrix.md) — local and CI validation layers +- [`docs/testing/local-docker-openclaw-debug.md`](docs/testing/local-docker-openclaw-debug.md) — rebuild the isolated Ubuntu/OpenClaw Docker target used for recipe debugging + ## License Proprietary. All rights reserved. diff --git a/clawpal-core/src/ssh/mod.rs b/clawpal-core/src/ssh/mod.rs index 2f278b3d..f42d248c 100644 --- a/clawpal-core/src/ssh/mod.rs +++ b/clawpal-core/src/ssh/mod.rs @@ -65,6 +65,16 @@ const RUSSH_SFTP_TIMEOUT_SECS: u64 = 30; #[derive(Clone)] struct SshHandler; +fn russh_exec_timeout_secs_from_env_var(raw: Option) -> u64 { + raw.and_then(|value| value.trim().parse::().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(RUSSH_EXEC_TIMEOUT_SECS) +} + +fn russh_exec_timeout_secs() -> u64 { + russh_exec_timeout_secs_from_env_var(std::env::var("CLAWPAL_RUSSH_EXEC_TIMEOUT_SECS").ok()) +} + #[async_trait::async_trait] impl client::Handler for SshHandler { type Error = russh::Error; @@ -147,7 +157,8 @@ impl SshSession { .await .map_err(|e| SshError::CommandFailed(e.to_string()))?; - let wait_result = timeout(Duration::from_secs(RUSSH_EXEC_TIMEOUT_SECS), async { + let exec_timeout_secs = russh_exec_timeout_secs(); + let wait_result = timeout(Duration::from_secs(exec_timeout_secs), async { let mut stdout = Vec::new(); let mut stderr = Vec::new(); let mut exit_code = -1; @@ -170,9 +181,7 @@ impl SshSession { .await; let (stdout, stderr, exit_code) = wait_result.map_err(|_| { - SshError::CommandFailed(format!( - "russh exec timed out after {RUSSH_EXEC_TIMEOUT_SECS}s" - )) + SshError::CommandFailed(format!("russh exec timed out after {exec_timeout_secs}s")) })?; Ok(ExecResult { @@ -948,4 +957,26 @@ mod tests { assert!(p.contains("id_ed25519") || p.contains("id_rsa")); } } + + #[test] + fn russh_exec_timeout_secs_uses_default_without_env_override() { + assert_eq!( + russh_exec_timeout_secs_from_env_var(None), + RUSSH_EXEC_TIMEOUT_SECS + ); + assert_eq!( + russh_exec_timeout_secs_from_env_var(Some(String::new())), + RUSSH_EXEC_TIMEOUT_SECS + ); + assert_eq!( + russh_exec_timeout_secs_from_env_var(Some("not-a-number".into())), + RUSSH_EXEC_TIMEOUT_SECS + ); + } + + #[test] + fn russh_exec_timeout_secs_accepts_positive_env_override() { + assert_eq!(russh_exec_timeout_secs_from_env_var(Some("60".into())), 60); + assert_eq!(russh_exec_timeout_secs_from_env_var(Some("5".into())), 5); + } } diff --git a/docs/mvp-checklist.md b/docs/mvp-checklist.md index 06d9e37c..11f6ffd5 100644 --- a/docs/mvp-checklist.md +++ b/docs/mvp-checklist.md @@ -54,3 +54,13 @@ - [x] 每步显示执行结果、错误态重试入口、命令摘要 - [x] 完成 `ready` 后可直接衔接 Doctor/Recipes 配置流程 - [ ] 四种方式接入真实执行器(当前为可审计命令计划与流程骨架) + +## 8. Recipe Authoring Workbench(v0.5) + +- [x] 内置 recipe 可 `Fork to workspace` +- [x] Workspace recipe 支持 `New / Save / Save As / Delete` +- [x] UI 可直接编辑 canonical recipe source,并通过后端做 validate / list / plan +- [x] Studio 支持 sample params 与 live plan preview +- [x] Draft 可直接进入 Cook 并执行 +- [x] Runtime run 可追溯到 `source origin / source digest / workspace path` +- [x] 至少一个 workspace recipe 可在 `Source / Form` 模式之间往返且不丢关键字段 diff --git a/docs/plans/2026-03-11-recipe-platform-executor-plan.md b/docs/plans/2026-03-11-recipe-platform-executor-plan.md new file mode 100644 index 00000000..428a93b9 --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-executor-plan.md @@ -0,0 +1,153 @@ +# Recipe Platform Executor Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 把已编译的 `ExecutionSpec` 落到现有 local/remote 执行层,优先支持 systemd-backed `job/service/schedule/attachment`。 + +**Architecture:** 这一部分不引入独立的 `reciped` 守护进程,而是把 `ExecutionSpec` 物化成当前系统已经擅长的命令计划。local 复用 `install/runners/local.rs`,remote 复用 `install/runners/remote_ssh.rs` 和现有 SSH/SFTP 能力。 + +**Deferred / Not in phase 1:** 本计划只覆盖 `ExecutionSpec` 到现有 local/SSH runner 的直接物化和执行入口。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;`schedule` 仅下发 systemd timer/unit,不承担持久调度控制面。 + +**Tech Stack:** Rust, systemd, systemd-run, SSH/SFTP, Tauri commands, Cargo tests + +--- + +### Task 1: 新增 ExecutionSpec 执行计划物化层 + +**Files:** +- Create: `src-tauri/src/recipe_executor.rs` +- Create: `src-tauri/src/recipe_runtime/systemd.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn job_spec_materializes_to_systemd_run_command() { + let spec = sample_job_spec(); + let plan = materialize_execution_plan(&spec).unwrap(); + assert!(plan.commands.iter().any(|cmd| cmd.join(" ").contains("systemd-run"))); +} + +#[test] +fn schedule_spec_references_job_launch_ref() { + let spec = sample_schedule_spec(); + let plan = materialize_execution_plan(&spec).unwrap(); + assert!(plan.resources.iter().any(|ref_id| ref_id == "schedule/hourly")); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because the executor layer does not exist. + +**Step 3: Write the minimal implementation** + +- `job` -> `systemd-run --unit clawpal-job-*` +- `service` -> 受控 unit 或 drop-in 文件 +- `schedule` -> `systemd timer` + `job` launch target +- `attachment` -> 先只支持 `systemdDropIn` / `envPatch` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_executor.rs src-tauri/src/recipe_runtime/systemd.rs src-tauri/src/recipe_executor_tests.rs src-tauri/src/lib.rs +git commit -m "feat: materialize recipe specs into systemd execution plans" +``` + +### Task 2: 接入 local / remote runner + +**Files:** +- Modify: `src-tauri/src/install/runners/local.rs` +- Modify: `src-tauri/src/install/runners/remote_ssh.rs` +- Modify: `src-tauri/src/ssh.rs` +- Modify: `src-tauri/src/cli_runner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn local_target_uses_local_runner() { + let route = route_execution(sample_target("local")); + assert_eq!(route.runner, "local"); +} + +#[test] +fn remote_target_uses_remote_ssh_runner() { + let route = route_execution(sample_target("remote")); + assert_eq!(route.runner, "remote_ssh"); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because routing is not implemented. + +**Step 3: Write the minimal implementation** + +- 增加 target routing,把 `ExecutionSpec.target` 路由到 local 或 remote SSH +- 保留现有 command queue 能力,`ExecutionSpec` 只负责生成可执行命令列表 +- 先不支持 workflow、人工审批恢复、后台持久调度 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/install/runners/local.rs src-tauri/src/install/runners/remote_ssh.rs src-tauri/src/ssh.rs src-tauri/src/cli_runner.rs src-tauri/src/commands/mod.rs src-tauri/src/recipe_executor_tests.rs +git commit -m "feat: route recipe execution through local and remote runners" +``` + +### Task 3: 暴露执行入口与最小回滚骨架 + +**Files:** +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Test: `src-tauri/src/recipe_executor_tests.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn execute_recipe_returns_run_id_and_summary() { + let result = execute_recipe(sample_execution_request()).unwrap(); + assert!(!result.run_id.is_empty()); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test recipe_executor_tests` +Expected: FAIL because execute API is not exposed. + +**Step 3: Write the minimal implementation** + +- 增加 `execute_recipe` command +- 返回 `runId`, `instanceId`, `summary`, `warnings` +- 回滚只提供骨架入口,先复用现有 config snapshot / rollback 能力 + +**Step 4: Run test to verify it passes** + +Run: `cargo test recipe_executor_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/commands/mod.rs src/lib/api.ts src/lib/types.ts src-tauri/src/recipe_executor_tests.rs +git commit -m "feat: expose recipe execution api and rollback scaffold" +``` diff --git a/docs/plans/2026-03-11-recipe-platform-foundation-plan.md b/docs/plans/2026-03-11-recipe-platform-foundation-plan.md new file mode 100644 index 00000000..75d5a1ab --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-foundation-plan.md @@ -0,0 +1,170 @@ +# Recipe Platform Foundation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 给 ClawPal 现有 recipe 体系补上 `RecipeBundle -> Runner Contract -> ExecutionSpec` 的基础模型、兼容编译层和 plan preview API。 + +**Architecture:** 第一部分只做“声明、编译、校验、预览”,不做真正的新执行器。现有 `step-based recipe` 继续可用,但后端会多一层 IR,把现有 recipe 编译成结构化 plan,供审批摘要、diff 和执行摘要复用。 + +**Deferred / Not in phase 1:** 本计划只覆盖 bundle/schema、兼容编译、静态校验和 plan preview。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;`secrets` 在这一阶段只保留引用与校验,不引入集中密钥分发或并发协调能力。 + +**Tech Stack:** Tauri 2, Rust, React 18, TypeScript, Bun, Cargo, JSON Schema, YAML/JSON parsing + +--- + +### Task 1: 新增 RecipeBundle 与 ExecutionSpec 核心模型 + +**Files:** +- Create: `src-tauri/src/recipe_bundle.rs` +- Create: `src-tauri/src/execution_spec.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Test: `src-tauri/src/recipe_bundle_tests.rs` +- Test: `src-tauri/src/execution_spec_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn recipe_bundle_rejects_unknown_execution_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +execution: { supportedKinds: [workflow] }"#; + assert!(parse_recipe_bundle(raw).is_err()); +} + +#[test] +fn execution_spec_rejects_inline_secret_value() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +secrets: { bindings: [{ id: "k", source: "plain://abc" }] }"#; + assert!(parse_execution_spec(raw).is_err()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_bundle_tests execution_spec_tests` +Expected: FAIL because the modules do not exist yet. + +**Step 3: Write the minimal implementation** + +- 定义 `RecipeBundle` 最小字段集:`metadata`, `compatibility`, `inputs`, `capabilities`, `resources`, `execution`, `runner`, `outputs` +- 定义 `ExecutionSpec` 最小字段集:`metadata`, `source`, `target`, `execution`, `capabilities`, `resources`, `secrets`, `desired_state`, `actions`, `outputs` +- 先实现 4 个硬约束: + - `execution.kind` 仅允许 `job | service | schedule | attachment` + - secret source 不允许明文协议 + - `usedCapabilities` 不得超出 bundle 上限 + - `claims` 不得出现未知 resource kind + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_bundle_tests execution_spec_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_bundle.rs src-tauri/src/execution_spec.rs src-tauri/src/recipe_bundle_tests.rs src-tauri/src/execution_spec_tests.rs src-tauri/src/lib.rs src/lib/types.ts +git commit -m "feat: add recipe bundle and execution spec primitives" +``` + +### Task 2: 给现有 step-based recipe 增加兼容编译层 + +**Files:** +- Create: `src-tauri/src/recipe_adapter.rs` +- Modify: `src-tauri/src/recipe.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src-tauri/src/recipe_adapter_tests.rs` + +**Step 1: Write the failing test** + +```rust +#[test] +fn legacy_recipe_compiles_to_attachment_or_job_spec() { + let recipe = builtin_recipes().into_iter().find(|r| r.id == "dedicated-channel-agent").unwrap(); + let spec = compile_legacy_recipe_to_spec(&recipe, sample_params()).unwrap(); + assert!(matches!(spec.execution.kind.as_str(), "attachment" | "job")); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test recipe_adapter_tests` +Expected: FAIL because the adapter does not exist. + +**Step 3: Write the minimal implementation** + +- 增加 `compile_legacy_recipe_to_spec(recipe, params)` 入口 +- `config_patch` 映射到 `attachment` 或 `file` 资源 +- `create_agent` / `bind_channel` / `setup_identity` 先映射到 `job` actions +- 保留当前 `recipes.json` 结构,先不引入新的 bundle 文件格式 + +**Step 4: Run test to verify it passes** + +Run: `cargo test recipe_adapter_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_adapter.rs src-tauri/src/recipe.rs src-tauri/src/commands/mod.rs src-tauri/src/recipe_adapter_tests.rs +git commit -m "feat: compile legacy recipes into structured specs" +``` + +### Task 3: 增加 plan preview API 与确认摘要 + +**Files:** +- Create: `src-tauri/src/recipe_planner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Create: `src/components/RecipePlanPreview.tsx` +- Modify: `src/pages/Cook.tsx` +- Test: `src-tauri/src/recipe_planner_tests.rs` +- Test: `src/components/__tests__/RecipePlanPreview.test.tsx` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn plan_recipe_returns_capabilities_claims_and_digest() { + let plan = build_recipe_plan(sample_bundle(), sample_inputs(), sample_facts()).unwrap(); + assert!(!plan.used_capabilities.is_empty()); + assert!(!plan.concrete_claims.is_empty()); + assert!(!plan.execution_spec_digest.is_empty()); +} +``` + +```tsx +it("renders capability and resource summaries in the confirm phase", async () => { + render(); + expect(screen.getByText(/service.manage/i)).toBeInTheDocument(); + expect(screen.getByText(/path/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_planner_tests` +Run: `bun test src/components/__tests__/RecipePlanPreview.test.tsx` +Expected: FAIL because no planning API or preview component exists. + +**Step 3: Write the minimal implementation** + +- 新增 `plan_recipe` Tauri command +- 返回 `summary`, `usedCapabilities`, `concreteClaims`, `executionSpecDigest`, `warnings` +- `Cook.tsx` 确认阶段改为展示结构化计划,而不是只列 step label + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_planner_tests` +Run: `bun test src/components/__tests__/RecipePlanPreview.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_planner.rs src-tauri/src/recipe_planner_tests.rs src-tauri/src/commands/mod.rs src/lib/api.ts src/lib/types.ts src/components/RecipePlanPreview.tsx src/components/__tests__/RecipePlanPreview.test.tsx src/pages/Cook.tsx +git commit -m "feat: add recipe planning preview and approval summary" +``` diff --git a/docs/plans/2026-03-11-recipe-platform-runtime-plan.md b/docs/plans/2026-03-11-recipe-platform-runtime-plan.md new file mode 100644 index 00000000..78e216df --- /dev/null +++ b/docs/plans/2026-03-11-recipe-platform-runtime-plan.md @@ -0,0 +1,143 @@ +# Recipe Platform Runtime Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在不引入远端守护进程的前提下,先把 `RecipeInstance / Run / Artifact / ResourceClaim` 做成本地可追踪运行时,并接入现有页面。 + +**Architecture:** runtime 数据先落在本地 `.clawpal/recipe-runtime/` 的 JSON index 中,作为 phase 1 临时状态层。这样可以先打通实例列表、运行记录、产物视图和资源占用展示,后续再平滑迁到 VPS 侧 SQLite。 + +**Deferred / Not in phase 1:** 本计划只覆盖本地 `.clawpal/recipe-runtime/` JSON store、实例/运行/产物索引和页面展示。phase 1 明确不包含远端 `reciped`、workflow engine、durable scheduler state、OPA/Rego policy plane、secret broker 或 lock manager;任何远端常驻控制面、集中策略决策、集中密钥分发和分布式锁统一留到 phase 2。 + +**Tech Stack:** Rust, Tauri, React 18, TypeScript, JSON persistence, Bun, Cargo + +--- + +### Task 1: 增加运行时 store 与索引模型 + +**Files:** +- Create: `src-tauri/src/recipe_store.rs` +- Modify: `src-tauri/src/models.rs` +- Modify: `src-tauri/src/lib.rs` +- Test: `src-tauri/src/recipe_store_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn record_run_persists_instance_and_artifacts() { + let store = RecipeStore::for_test(); + let run = store.record_run(sample_run()).unwrap(); + assert_eq!(store.list_runs("inst_01").unwrap()[0].id, run.id); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_store_tests` +Expected: FAIL because the runtime store does not exist. + +**Step 3: Write the minimal implementation** + +- 定义 `RecipeInstance`, `Run`, `Artifact`, `ResourceClaim` +- 在 `.clawpal/recipe-runtime/` 下保存最小 JSON index +- 支持 `record_run`, `list_runs`, `list_instances` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_store_tests` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_store.rs src-tauri/src/recipe_store_tests.rs src-tauri/src/models.rs src-tauri/src/lib.rs +git commit -m "feat: add recipe runtime store for instances and runs" +``` + +### Task 2: 把 runtime 数据接到现有页面 + +**Files:** +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/pages/Orchestrator.tsx` +- Modify: `src/pages/History.tsx` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/types.ts` +- Test: `src/pages/__tests__/Recipes.test.tsx` +- Test: `src/pages/__tests__/Orchestrator.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("shows recipe instance status and recent run summary", async () => { + render( {}} />); + expect(await screen.findByText(/recent run/i)).toBeInTheDocument(); +}); +``` + +```tsx +it("shows artifacts and resource claims in orchestrator", async () => { + render(); + expect(await screen.findByText(/resource claims/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx` +Expected: FAIL because the pages do not render runtime data yet. + +**Step 3: Write the minimal implementation** + +- `Recipes.tsx` 增加实例状态、最近运行、进入 dashboard 的入口 +- `Orchestrator.tsx` 展示 run timeline、artifact 列表、resource claims +- `History.tsx` 只补最小链接,不复制一套新的历史系统 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/Recipes.tsx src/pages/Orchestrator.tsx src/pages/History.tsx src/lib/api.ts src/lib/types.ts src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx +git commit -m "feat: surface recipe runtime state in recipes and orchestrator pages" +``` + +### Task 3: 记录 phase 2 迁移边界,避免 phase 1 过度设计 + +**Files:** +- Modify: `docs/plans/2026-03-11-recipe-platform-foundation-plan.md` +- Modify: `docs/plans/2026-03-11-recipe-platform-executor-plan.md` +- Modify: `docs/plans/2026-03-11-recipe-platform-runtime-plan.md` + +**Step 1: Write the failing check** + +创建一个人工 checklist,逐条确认这 3 份计划没有把以下内容混进 phase 1: +- 远端 `reciped` +- workflow engine +- scheduler durable state +- OPA/Rego policy plane +- secret broker / lock manager + +**Step 2: Run the check** + +Run: `rg -n "reciped|workflow|scheduler|OPA|Rego|secret broker|lock manager" docs/plans/2026-03-11-recipe-platform-*-plan.md` +Expected: only deferred or explicitly excluded references remain. + +**Step 3: Write the minimal implementation** + +- 在 3 份计划中补 “Deferred / Not in phase 1” 边界说明 +- 确保后续执行不会误把第二阶段内容拉进第一阶段 + +**Step 4: Run the check again** + +Run: `rg -n "reciped|workflow|scheduler|OPA|Rego|secret broker|lock manager" docs/plans/2026-03-11-recipe-platform-*-plan.md` +Expected: only deferred references remain. + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-11-recipe-platform-foundation-plan.md docs/plans/2026-03-11-recipe-platform-executor-plan.md docs/plans/2026-03-11-recipe-platform-runtime-plan.md +git commit -m "docs: clarify phase boundaries for recipe runtime rollout" +``` diff --git a/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md b/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md new file mode 100644 index 00000000..f4ec60df --- /dev/null +++ b/docs/plans/2026-03-12-recipe-authoring-workbench-plan.md @@ -0,0 +1,548 @@ +# Recipe Authoring Workbench Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 给 ClawPal 的 Recipe 系统补齐“作者态工作台”,支持 fork 内置 recipe、编辑结构化 source、保存到本地 workspace、校验、预览、试跑,以及把运行记录关联回 recipe source。 + +**Architecture:** 以结构化 recipe source JSON 作为唯一真相,后端负责 parse、validate、plan、save 和 runtime traceability,前端只维护 draft 编辑状态和工作流 UI。内置 recipe 保持只读,通过 `Fork to workspace` 进入工作区;workspace recipe 采用“一文件一个 recipe”的本地模型,默认落到 `~/.clawpal/recipes/workspace/`,保存使用现有原子写入能力。 + +**Tech Stack:** Tauri 2, Rust, React 18, TypeScript, Bun, Cargo, JSON/JSON5 parsing, current RecipeBundle + ExecutionSpec pipeline + +**Deferred / Not in this plan:** 不做远端 recipe 文件编辑,不支持直接写回 HTTP URL source,不做多人协作或云端同步,不做 AST 级 merge/rebase,不做可视化拖拽 builder。 + +## Delivered Notes + +- Status: delivered on branch `chore/recipe-plan-test-fix` +- Task 1 delivered in `d321e81 feat: add recipe workspace storage commands` +- Task 1 test temp-root cleanup follow-up landed in `f4685d4 chore: clean recipe workspace test temp roots` +- Task 2 delivered in `ed17efd feat: add recipe source validation and draft planning` +- Task 3 delivered in `ccb9436 feat: add recipe studio source editor` +- Task 4 delivered in `697c73c feat: add recipe workspace save flows` +- Task 5 delivered in `d0c044e feat: add recipe studio validation and plan sandbox` +- Task 6 delivered in `8268928 feat: execute recipe drafts from studio` +- Task 7 delivered in `b9124bc feat: track recipe source metadata in runtime history` +- Task 8 delivered in `5eff6ad feat: add recipe studio form mode` + +## Final Verification + +- `cargo test recipe_ --lib`: PASS +- `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx`: PASS +- `bun run typecheck`: PASS + +--- + +### Task 1: 建立 workspace recipe 文件模型与后端命令 + +**Files:** +- Create: `src-tauri/src/recipe_workspace.rs` +- Modify: `src-tauri/src/models.rs` +- Modify: `src-tauri/src/config_io.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src-tauri/src/recipe_workspace_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn workspace_recipe_save_writes_under_clawpal_recipe_workspace() { + let store = RecipeWorkspace::for_test(); + let result = store.save_recipe_source("channel-persona", SAMPLE_SOURCE).unwrap(); + assert!(result.path.ends_with("recipes/workspace/channel-persona.recipe.json")); +} + +#[test] +fn workspace_recipe_save_rejects_parent_traversal() { + let store = RecipeWorkspace::for_test(); + assert!(store.save_recipe_source("../escape", SAMPLE_SOURCE).is_err()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_workspace_tests --lib` +Expected: FAIL because the workspace module and commands do not exist. + +**Step 3: Write the minimal implementation** + +- 定义 workspace root:`resolve_paths().clawpal_dir.join("recipes").join("workspace")` +- 增加 `RecipeWorkspace` 负责: + - 规范化 recipe slug + - 解析 recipe 文件路径 + - 原子读写 source text + - 列出 workspace recipe 文件 +- 新增 Tauri commands: + - `list_recipe_workspace_entries` + - `read_recipe_workspace_source` + - `save_recipe_workspace_source` + - `delete_recipe_workspace_source` +- 先不做 rename,使用 `Save As` 覆盖 rename 需求 +- 前端 types 里增加: + - `RecipeWorkspaceEntry` + - `RecipeSourceSaveResult` + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_workspace_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_workspace.rs src-tauri/src/models.rs src-tauri/src/config_io.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src-tauri/src/recipe_workspace_tests.rs +git commit -m "feat: add recipe workspace storage commands" +``` + +### Task 2: 增加 raw source 校验、解析和 draft planning API + +**Files:** +- Modify: `src-tauri/src/recipe.rs` +- Modify: `src-tauri/src/recipe_adapter.rs` +- Modify: `src-tauri/src/recipe_planner.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src-tauri/src/recipe_adapter_tests.rs` +- Test: `src-tauri/src/recipe_planner_tests.rs` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn exported_recipe_source_validates_as_structured_document() { + let source = export_recipe_source(&builtin_recipe()).unwrap(); + let diagnostics = validate_recipe_source(&source).unwrap(); + assert!(diagnostics.errors.is_empty()); +} + +#[test] +fn plan_recipe_source_uses_unsaved_draft_text() { + let plan = plan_recipe_source("channel-persona", SAMPLE_DRAFT_SOURCE, sample_params()).unwrap(); + assert_eq!(plan.summary.recipe_id, "channel-persona"); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_adapter_tests recipe_planner_tests --lib` +Expected: FAIL because raw source validation and draft planning commands do not exist. + +**Step 3: Write the minimal implementation** + +- 增加基于 source text 的后端入口: + - `validate_recipe_source` + - `list_recipes_from_source_text` + - `plan_recipe_source` +- 诊断结构分三层: + - parse/schema error + - bundle/spec consistency error + - `steps` 与 `actions` 对齐 error +- `plan_recipe_source` 必须支持“未保存 draft”直接预览 +- `export_recipe_source` 继续作为 canonicalization 入口 +- diagnostics 返回结构化位置和消息,不只是一条字符串 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_adapter_tests recipe_planner_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe.rs src-tauri/src/recipe_adapter.rs src-tauri/src/recipe_planner.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src-tauri/src/recipe_adapter_tests.rs src-tauri/src/recipe_planner_tests.rs +git commit -m "feat: add recipe source validation and draft planning" +``` + +### Task 3: 建立 Recipe Studio 路由和 Source Mode 编辑器 + +**Files:** +- Create: `src/pages/RecipeStudio.tsx` +- Create: `src/components/RecipeSourceEditor.tsx` +- Create: `src/components/RecipeValidationPanel.tsx` +- Modify: `src/App.tsx` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/components/RecipeCard.tsx` +- Modify: `src/lib/types.ts` +- Modify: `src/locales/en.json` +- Modify: `src/locales/zh.json` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` +- Test: `src/pages/__tests__/Recipes.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("opens studio from recipes and shows editable source", async () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue(expect.stringContaining('"kind": "ExecutionSpec"')); +}); +``` + +```tsx +it("shows fork button for builtin recipe cards", async () => { + render(); + expect(screen.getByText(/view source/i)).toBeInTheDocument(); + expect(screen.getByText(/fork to workspace/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx` +Expected: FAIL because studio route and source editor do not exist. + +**Step 3: Write the minimal implementation** + +- 新增 `RecipeStudio` 页面,支持: + - source textarea/editor + - dirty state + - current recipe label + - validation summary panel +- `Recipes` 页面增加入口: + - `View source` + - `Edit` + - `Fork to workspace` +- `App.tsx` 增加 recipe studio route 和所需状态: + - `recipeEditorSource` + - `recipeEditorRecipeId` + - `recipeEditorOrigin` +- 内置 recipe 在 studio 中默认只读,fork 后切换为可编辑 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeSourceEditor.tsx src/components/RecipeValidationPanel.tsx src/App.tsx src/pages/Recipes.tsx src/components/RecipeCard.tsx src/lib/types.ts src/locales/en.json src/locales/zh.json src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx +git commit -m "feat: add recipe studio source editor" +``` + +### Task 4: 打通 Save / Save As / New / Delete / Fork 工作流 + +**Files:** +- Modify: `src/pages/RecipeStudio.tsx` +- Create: `src/components/RecipeSaveDialog.tsx` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Modify: `src/lib/types.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` +- Test: `src-tauri/src/recipe_workspace_tests.rs` + +**Step 1: Write the failing tests** + +```tsx +it("marks studio dirty and saves to workspace file", async () => { + render(); + await user.type(screen.getByRole("textbox"), "\n"); + await user.click(screen.getByRole("button", { name: /save/i })); + expect(api.saveRecipeWorkspaceSource).toHaveBeenCalled(); +}); +``` + +```rust +#[test] +fn delete_workspace_recipe_removes_saved_file() { + let store = RecipeWorkspace::for_test(); + let saved = store.save_recipe_source("persona", SAMPLE_SOURCE).unwrap(); + store.delete_recipe_source(saved.slug.as_str()).unwrap(); + assert!(!saved.path.exists()); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Run: `cargo test recipe_workspace_tests --lib` +Expected: FAIL because save/delete/fork workflows are incomplete. + +**Step 3: Write the minimal implementation** + +- `RecipeStudio` 支持: + - `New` + - `Save` + - `Save As` + - `Delete` + - `Fork builtin recipe` +- `Save` 仅对 workspace recipe 可用 +- `Save As` 让用户输入 slug;slug 校验在后端做最终裁决 +- 保存成功后重新拉取 `Recipes` 列表,并保持当前 editor 打开的就是保存后的 workspace recipe +- 对未保存离开增加确认 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Run: `cargo test recipe_workspace_tests --lib` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeSaveDialog.tsx src/pages/Recipes.tsx src/lib/api.ts src/lib/use-api.ts src/lib/types.ts src/pages/__tests__/RecipeStudio.test.tsx src-tauri/src/recipe_workspace_tests.rs +git commit -m "feat: add recipe workspace save flows" +``` + +### Task 5: 在 Studio 中加入 live validation 和 sample params sandbox + +**Files:** +- Modify: `src/pages/RecipeStudio.tsx` +- Modify: `src/components/RecipeValidationPanel.tsx` +- Create: `src/components/RecipeSampleParamsForm.tsx` +- Modify: `src/components/RecipePlanPreview.tsx` +- Modify: `src/lib/types.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("shows planner warnings for unsaved draft source", async () => { + render(); + await user.type(screen.getByLabelText(/persona/i), "Keep answers concise"); + await user.click(screen.getByRole("button", { name: /preview plan/i })); + expect(await screen.findByText(/optional step/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Expected: FAIL because studio cannot preview draft plans yet. + +**Step 3: Write the minimal implementation** + +- 增加 sample params form,优先复用现有 `ParamForm` 的字段渲染逻辑 +- 调用 `validate_recipe_source` 实时显示 diagnostics +- 调用 `plan_recipe_source` 预览 unsaved draft 的结构化 plan +- 复用现有 `RecipePlanPreview` +- 把 parse error、schema error、plan error 分开展示 + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pages/RecipeStudio.tsx src/components/RecipeValidationPanel.tsx src/components/RecipeSampleParamsForm.tsx src/components/RecipePlanPreview.tsx src/lib/types.ts src/lib/api.ts src/lib/use-api.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: add recipe studio validation and plan sandbox" +``` + +### Task 6: 支持 draft recipe 直接进入 Cook 并执行 + +**Files:** +- Modify: `src/App.tsx` +- Modify: `src/pages/Cook.tsx` +- Modify: `src/pages/cook-execution.ts` +- Modify: `src/pages/cook-plan-context.ts` +- Modify: `src/lib/api.ts` +- Modify: `src/lib/use-api.ts` +- Modify: `src/lib/types.ts` +- Modify: `src-tauri/src/commands/mod.rs` +- Test: `src/pages/__tests__/cook-execution.test.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```tsx +it("can open cook from studio with unsaved draft source", async () => { + render(); + await user.click(screen.getByRole("button", { name: /cook draft/i })); + expect(mockNavigate).toHaveBeenCalledWith("cook"); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/cook-execution.test.ts` +Expected: FAIL because Cook only accepts saved recipe source/path. + +**Step 3: Write the minimal implementation** + +- `Cook` 增加 `recipeSourceText` 可选输入 +- `listRecipes` / `planRecipe` / `executeRecipe` 补 source-text 变体,允许对 draft 直接编译和执行 +- 保持 Cook 文案和阶段不变,只扩输入来源 +- 如果 draft 未保存,runtime 记录里标记 `sourceOrigin = draft` + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/cook-execution.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/App.tsx src/pages/Cook.tsx src/pages/cook-execution.ts src/pages/cook-plan-context.ts src/lib/api.ts src/lib/use-api.ts src/lib/types.ts src-tauri/src/commands/mod.rs src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: execute recipe drafts from studio" +``` + +### Task 7: 给 runtime run 补 recipe source traceability + +**Files:** +- Modify: `src-tauri/src/recipe_store.rs` +- Modify: `src-tauri/src/commands/mod.rs` +- Modify: `src-tauri/src/history.rs` +- Modify: `src/lib/types.ts` +- Modify: `src/pages/Recipes.tsx` +- Modify: `src/pages/Orchestrator.tsx` +- Modify: `src/pages/History.tsx` +- Test: `src-tauri/src/recipe_store_tests.rs` +- Test: `src/pages/__tests__/Recipes.test.tsx` +- Test: `src/pages/__tests__/Orchestrator.test.tsx` +- Test: `src/pages/__tests__/History.test.tsx` + +**Step 1: Write the failing tests** + +```rust +#[test] +fn recorded_run_persists_source_digest_and_origin() { + let store = RecipeStore::for_test(); + let run = sample_run_with_source(); + let recorded = store.record_run(run).unwrap(); + assert_eq!(recorded.source_digest.as_deref(), Some("digest-123")); + assert_eq!(recorded.source_origin.as_deref(), Some("workspace")); +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `cargo test recipe_store_tests --lib` +Expected: FAIL because run metadata does not contain source trace fields. + +**Step 3: Write the minimal implementation** + +- `RecipeRuntimeRun` 增加: + - `sourceDigest` + - `sourceVersion` + - `sourceOrigin` + - `workspacePath` +- `execute_recipe` 在 record run 前写入这些字段 +- `History` / `Orchestrator` / `Recipes` 面板显示“这次运行来自哪份 recipe source” +- 如果 source 来自 workspace,提供“Open in studio”入口 + +**Step 4: Run tests to verify they pass** + +Run: `cargo test recipe_store_tests --lib` +Run: `bun test src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src-tauri/src/recipe_store.rs src-tauri/src/commands/mod.rs src-tauri/src/history.rs src/lib/types.ts src/pages/Recipes.tsx src/pages/Orchestrator.tsx src/pages/History.tsx src-tauri/src/recipe_store_tests.rs src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx +git commit -m "feat: link runtime runs back to recipe source" +``` + +### Task 8: 增加 Form Mode,并与 canonical source 双向同步 + +**Files:** +- Create: `src/lib/recipe-editor-model.ts` +- Create: `src/components/RecipeFormEditor.tsx` +- Modify: `src/pages/RecipeStudio.tsx` +- Modify: `src/components/RecipeSourceEditor.tsx` +- Modify: `src/lib/types.ts` +- Test: `src/lib/__tests__/recipe-editor-model.test.ts` +- Test: `src/pages/__tests__/RecipeStudio.test.tsx` + +**Step 1: Write the failing tests** + +```ts +it("round-trips metadata params steps and execution template", () => { + const doc = parseRecipeSource(sampleSource); + const form = toRecipeEditorModel(doc); + const nextDoc = fromRecipeEditorModel(form); + expect(nextDoc.executionSpecTemplate.kind).toBe("ExecutionSpec"); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `bun test src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx` +Expected: FAIL because no form model exists. + +**Step 3: Write the minimal implementation** + +- 定义 canonical editor model,只覆盖: + - top-level metadata + - params + - steps + - action rows + - bundle capability/resource lists +- `RecipeStudio` 增加 `Source / Form` 两个 tab +- 双向同步策略: + - form 修改后重建 canonical source text + - source 修改后重建 form model +- 任一方向 parse 失败时,保留另一侧最后一个有效快照,不做 silent overwrite + +**Step 4: Run tests to verify they pass** + +Run: `bun test src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/lib/recipe-editor-model.ts src/components/RecipeFormEditor.tsx src/pages/RecipeStudio.tsx src/components/RecipeSourceEditor.tsx src/lib/types.ts src/lib/__tests__/recipe-editor-model.test.ts src/pages/__tests__/RecipeStudio.test.tsx +git commit -m "feat: add recipe studio form mode" +``` + +### Task 9: 文档、回归和收尾 + +**Files:** +- Modify: `docs/plans/2026-03-12-recipe-authoring-workbench-plan.md` +- Modify: `docs/mvp-checklist.md` +- Modify: `src/locales/en.json` +- Modify: `src/locales/zh.json` + +**Step 1: Run full relevant verification** + +Run: + +```bash +cargo test recipe_ --lib +bun test src/pages/__tests__/RecipeStudio.test.tsx src/pages/__tests__/Recipes.test.tsx src/pages/__tests__/cook-execution.test.ts src/pages/__tests__/Orchestrator.test.tsx src/pages/__tests__/History.test.tsx +bun run typecheck +``` + +Expected: PASS + +**Step 2: Fix any failing assertions and stale copy** + +- 更新文案、空态、按钮标签 +- 更新 plan 文档中的实际 commit hash +- 把已完成项从 plan 转为 delivered notes + +**Step 3: Commit** + +```bash +git add docs/plans/2026-03-12-recipe-authoring-workbench-plan.md docs/mvp-checklist.md src/locales/en.json src/locales/zh.json +git commit -m "docs: finalize recipe authoring workbench rollout notes" +``` + +--- + +## Recommended Execution Order + +1. Task 1-2 先把 workspace source 和 draft validate/plan API 打通。 +2. Task 3-4 再做 studio 和 save/fork 流程,形成真正 authoring 闭环。 +3. Task 5-6 接上 live preview 和 draft execute,把 authoring 和 Cook 贯通。 +4. Task 7 最后补 runtime traceability,保证运行记录可追溯。 +5. Task 8 作为完整作者体验的最后一层,在 source mode 稳定后再做。 + +## Acceptance Criteria + +- 可以从内置 recipe 一键 fork 到 workspace。 +- 可以在 UI 中直接编辑 canonical recipe source 并保存到本地文件。 +- 可以对未保存 draft 做 validate 和 plan preview。 +- 可以从 draft 直接进入 Cook 并执行。 +- Runtime run 可以追溯到 source digest / source origin / workspace path。 +- 至少一个 workspace recipe 可以通过 Form Mode 与 Source Mode 来回切换而不丢关键字段。 diff --git a/docs/recipe-authoring.md b/docs/recipe-authoring.md new file mode 100644 index 00000000..f85129e9 --- /dev/null +++ b/docs/recipe-authoring.md @@ -0,0 +1,727 @@ +# 如何编写一个 ClawPal Recipe + +这份文档描述的是当前仓库里真实可执行的 Recipe DSL,而不是早期草案。 + +目标读者: +- 需要新增预置 Recipe 的开发者 +- 需要维护 `examples/recipe-library/` 外部 Recipe 库的人 +- 需要理解 `Recipe Source -> ExecutionSpec -> runner` 这条链路的人 + +## 1. 先理解运行时模型 + +当前 ClawPal 的 Recipe 有两种入口: + +1. 作为预置 Recipe 随 App 打包,并在启动时 seed 到 workspace +2. 作为外部 Recipe library 在运行时导入 + +无论入口是什么,最终运行时载体都是 workspace 里的单文件 JSON: + +`~/.clawpal/recipes/workspace/.recipe.json` + +也就是说: +- source authoring 可以是目录结构 +- import/seed 之后会变成自包含单文件 +- runner 永远不直接依赖外部 `assets/` 目录 + +### Bundled Recipe 的升级规则 + +内置 bundled recipe 现在采用“`digest 判定,显式升级`”模型: + +- 首次启动时,如果 workspace 缺失,会自动 seed +- 如果 bundled source 更新了,但用户没有改本地副本,UI 会显示 `Update available` +- 如果用户改过本地副本,不会被静默覆盖 +- 只有用户显式点击升级,workspace copy 才会被替换 + +状态语义: + +- `upToDate` +- `updateAvailable` +- `localModified` +- `conflictedUpdate` + +这里 `version` 只用于展示;真正判断是否有升级,始终看 source `digest`。 + +### 来源、信任与批准 + +workspace recipe 会记录来源: + +- `bundled` +- `localImport` +- `remoteUrl` + +这会影响执行前的信任和批准规则: + +- `bundled` + 普通变更默认可执行,高风险动作需要批准 +- `localImport` + 中风险和高风险 recipe 首次执行前需要批准 +- `remoteUrl` + 任何会修改环境的 recipe 首次执行前都需要批准 + +批准是按 `workspace recipe + 当前 digest` 记忆的: + +- 同一个 digest 只需批准一次 +- 只要 recipe 被编辑、重新导入或升级,digest 变化,批准自动失效 + +## 2. 推荐的作者目录结构 + +新增一个可维护的 Recipe,推荐放在独立目录里,而不是直接写进 `src-tauri/recipes.json`。 + +当前仓库采用的结构是: + +```text +examples/recipe-library/ + dedicated-agent/ + recipe.json + agent-persona-pack/ + recipe.json + assets/ + personas/ + coach.md + researcher.md + channel-persona-pack/ + recipe.json + assets/ + personas/ + incident.md + support.md +``` + +规则: +- 每个 Recipe 一个目录 +- 目录里必须有 `recipe.json` +- 如需预设 markdown 文本,放到 `assets/` +- import 时只扫描 library 根目录下的一级子目录 + +## 3. 顶层文档形状 + +对于 library 里的 `recipe.json`,推荐写成单个 recipe 对象。 + +当前加载器支持三种形状: + +```json +{ "...": "single recipe object" } +``` + +```json +[ + { "...": "recipe 1" }, + { "...": "recipe 2" } +] +``` + +```json +{ + "recipes": [ + { "...": "recipe 1" }, + { "...": "recipe 2" } + ] +} +``` + +但有一个关键区别: +- `Load` 文件或 URL 时,可以接受三种形状 +- `Import` 外部 recipe library 时,`recipe.json` 必须是单个对象 + +因此,写新的 library recipe 时,直接使用单对象。 + +## 4. 一个完整 Recipe 的推荐结构 + +当前推荐写法: + +```json +{ + "id": "dedicated-agent", + "name": "Dedicated Agent", + "description": "Create an agent and set its identity and persona", + "version": "1.0.0", + "tags": ["agent", "identity", "persona"], + "difficulty": "easy", + "presentation": { + "resultSummary": "Created dedicated agent {{name}} ({{agent_id}})" + }, + "params": [], + "steps": [], + "bundle": {}, + "executionSpecTemplate": {}, + "clawpalImport": {} +} +``` + +字段职责: +- `id / name / description / version / tags / difficulty` + Recipe 元信息 +- `presentation` + 面向用户的结果文案 +- `params` + Configure 阶段的参数表单 +- `steps` + 面向用户的步骤文案 +- `bundle` + 声明 capability、resource claim、execution kind 的白名单 +- `executionSpecTemplate` + 真正要编译成什么 `ExecutionSpec` +- `clawpalImport` + 仅用于 library import 阶段的扩展元数据,不会保留在最终 workspace recipe 里 + +## 5. 参数字段怎么写 + +`params` 是数组,每项形状如下: + +```json +{ + "id": "agent_id", + "label": "Agent ID", + "type": "string", + "required": true, + "placeholder": "e.g. ops-bot", + "pattern": "^[a-z0-9-]+$", + "minLength": 3, + "maxLength": 32, + "defaultValue": "main", + "dependsOn": "advanced", + "options": [ + { "value": "coach", "label": "Coach" } + ] +} +``` + +当前前端支持的 `type`: +- `string` +- `number` +- `boolean` +- `textarea` +- `discord_guild` +- `discord_channel` +- `model_profile` +- `agent` + +UI 规则: +- `options` 非空时,优先渲染为下拉 +- `discord_guild` 从当前环境加载 guild 列表 +- `discord_channel` 从当前环境加载 channel 列表 +- `agent` 从当前环境加载 agent 列表 +- `model_profile` 从当前环境加载可用 model profiles +- `dependsOn` 当前仍是简单门控,不要依赖复杂表达式 + +实用建议: +- 长文本输入用 `textarea` +- 固定预设优先用 `options` +- `model_profile` 如果希望默认跟随环境,可用 `__default__` + +## 6. `steps` 和 `executionSpecTemplate.actions` 必须一一对应 + +`steps` 是给用户看的,`executionSpecTemplate.actions` 是给编译器和 runner 看的。 + +当前校验要求: +- `steps.len()` 必须等于 `executionSpecTemplate.actions.len()` +- 每一步的 `action` 应与对应 action 的 `kind` 保持一致 + +也就是说,`steps` 不是装饰层,它是用户理解“这次会做什么”的主入口。 + +## 7. 当前支持的 action surface + +当前 Recipe DSL 的 action 分两层: + +- 推荐层:高层业务动作,优先给大多数 recipe 作者使用 +- 高级层:CLI 原语动作,按 OpenClaw CLI 子命令 1:1 暴露 + +此外还有: +- 文档底座动作 +- 环境编排动作 +- legacy/escape hatch + +### 7.1 推荐的业务动作 + +- `create_agent` +- `delete_agent` +- `bind_agent` +- `unbind_agent` +- `set_agent_identity` +- `set_agent_model` +- `set_agent_persona` +- `clear_agent_persona` +- `set_channel_persona` +- `clear_channel_persona` + +推荐: +- 新的业务 recipe 优先使用业务动作 +- `set_agent_identity` 优于旧的 `setup_identity` +- `bind_agent` / `unbind_agent` 优于旧的 `bind_channel` / `unbind_channel` + +### 7.2 文档动作 + +- `upsert_markdown_document` +- `delete_markdown_document` + +这是高级/底座动作,适合: +- 写 agent 默认 markdown 文档 +- 直接控制 section upsert 或 whole-file replace + +### 7.3 环境动作 + +- `ensure_model_profile` +- `delete_model_profile` +- `ensure_provider_auth` +- `delete_provider_auth` + +这组动作负责: +- 确保目标环境存在可用 profile +- 必要时同步 profile 依赖的 auth/secret +- 清理不再需要的 auth/profile + +### 7.4 CLI 原语动作 + +对于需要直接复用 OpenClaw CLI 的高级 recipe,可以使用 CLI 原语动作。 + +当前 catalog 覆盖了这些命令组: +- `agents` +- `config` +- `models` +- `channels` +- `secrets` + +例子: +- `list_agents` -> `openclaw agents list` +- `list_agent_bindings` -> `openclaw agents bindings` +- `show_config_file` -> `openclaw config file` +- `get_config_value` / `set_config_value` / `unset_config_value` +- `models_status` / `list_models` / `set_default_model` +- `list_channels` / `channels_status` / `inspect_channel_capabilities` +- `reload_secrets` / `audit_secrets` / `apply_secrets_plan` + +完整清单见:[recipe-cli-action-catalog.md](./recipe-cli-action-catalog.md) + +注意: +- 文档里出现并不等于 runner 一定支持执行 +- interactive 或携带 secret payload 的 CLI 子命令,只会记录在 catalog 里,不建议写进 recipe + +## 7.6 Review 阶段现在会严格阻断什么 + +当前 `Cook -> Review` 会把下面这些情况当成阻断项,而不是“执行后再失败”: + +- 当前 recipe 需要批准,但还没批准 +- auth 预检返回 `error` +- destructive action 默认删除仍被引用的资源 + +因此作者在设计 recipe 时,应优先做到: + +- 结果语义清晰 +- claim 和 capability 可稳定推导 +- destructive 行为显式声明 `force` / `rebind` 之类的意图参数 + +### 7.5 兼容 / escape hatch + +- `config_patch` +- `setup_identity` +- `bind_channel` +- `unbind_channel` + +保留用于兼容旧 recipe 或极少数低层配置改写,但不建议作为 bundled recipe 的主路径。 + +## 8. 各类 action 的常见输入 + +### `create_agent` + +```json +{ + "kind": "create_agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}" + } +} +``` + +说明: +- 旧的 `independent` 字段仍可被兼容读取,但不再推荐使用 +- workspace 由 OpenClaw 默认策略决定;runner 不再把 `agentId` 直接当成 workspace 路径 + +### `set_agent_identity` + +```json +{ + "kind": "set_agent_identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } +} +``` + +### `set_agent_persona` + +```json +{ + "kind": "set_agent_persona", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } +} +``` + +### `bind_agent` + +```json +{ + "kind": "bind_agent", + "args": { + "agentId": "{{agent_id}}", + "binding": "discord:{{channel_id}}" + } +} +``` + +### `set_channel_persona` + +```json +{ + "kind": "set_channel_persona", + "args": { + "channelType": "discord", + "guildId": "{{guild_id}}", + "peerId": "{{channel_id}}", + "persona": "{{presetMap:persona_preset}}" + } +} +``` + +### `upsert_markdown_document` + +```json +"args": { + "target": { + "scope": "agent", + "agentId": "{{agent_id}}", + "path": "IDENTITY.md" + }, + "mode": "replace", + "content": "- Name: {{name}}\n\n## Persona\n{{persona}}\n" +} +``` + +支持的 `target.scope`: +- `agent` +- `home` +- `absolute` + +支持的 `mode`: +- `replace` +- `upsertSection` + +`upsertSection` 需要额外提供: +- `heading` +- 可选 `createIfMissing` + +### `delete_markdown_document` + +```json +"args": { + "target": { + "scope": "agent", + "agentId": "{{agent_id}}", + "path": "PLAYBOOK.md" + }, + "missingOk": true +} +``` + +### `ensure_model_profile` + +```json +{ + "kind": "ensure_model_profile", + "args": { + "profileId": "{{model}}" + } +} +``` + +### `ensure_provider_auth` + +```json +{ + "kind": "ensure_provider_auth", + "args": { + "provider": "openrouter", + "authRef": "openrouter:default" + } +} +``` + +### destructive 动作 + +以下动作默认会做引用检查,仍被引用时会失败: +- `delete_agent` +- `delete_model_profile` +- `delete_provider_auth` + +显式 override: +- `delete_agent.force` +- `delete_agent.rebindChannelsTo` +- `delete_provider_auth.force` +- `delete_model_profile.deleteAuthRef` + +### CLI 原语动作例子 + +```json +{ + "kind": "get_config_value", + "args": { + "path": "gateway.port" + } +} +``` + +```json +{ + "kind": "models_status", + "args": { + "probe": true, + "probeProvider": "openai" + } +} +``` + +## 9. `bundle` 写什么 + +`bundle` 的作用是声明: +- 允许使用哪些 capability +- 允许触碰哪些 resource kind +- 支持哪些 execution kind + +例如: + +```json +"bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "dedicated-agent", + "version": "1.0.0", + "description": "Create a dedicated agent" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.manage", "agent.identity.write", "model.manage", "secret.sync"] + }, + "resources": { + "supportedKinds": ["agent", "modelProfile"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-agent" }] +} +``` + +当前常见 capability: +- `agent.manage` +- `agent.identity.write` +- `binding.manage` +- `config.write` +- `document.write` +- `document.delete` +- `model.manage` +- `auth.manage` +- `secret.sync` + +当前常见 resource claim kind: +- `agent` +- `channel` +- `file` +- `document` +- `modelProfile` +- `authProfile` + +## 10. `executionSpecTemplate` 写什么 + +它定义编译后真正的 `ExecutionSpec`,通常至少要包含: + +```json +"executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "dedicated-agent" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": ["model.manage", "secret.sync", "agent.manage", "agent.identity.write"] + }, + "resources": { + "claims": [ + { "kind": "modelProfile", "id": "{{model}}" }, + { "kind": "agent", "id": "{{agent_id}}" } + ] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 4 + }, + "actions": [ + { + "kind": "ensure_model_profile", + "name": "Prepare model access", + "args": { + "profileId": "{{model}}" + } + }, + { + "kind": "create_agent", + "name": "Create dedicated agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}" + } + }, + { + "kind": "set_agent_identity", + "name": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "kind": "set_agent_persona", + "name": "Set agent persona", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{persona}}" + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-agent" }] +} +``` + +当前 `execution.kind` 支持: +- `job` +- `service` +- `schedule` +- `attachment` + +对大多数业务 recipe: +- 一次性业务动作优先用 `job` +- 配置附着类动作可用 `attachment` + +## 11. 模板变量 + +当前支持两类最常用模板。 + +### 11.1 参数替换 + +```json +"agentId": "{{agent_id}}" +``` + +### 11.2 preset map 替换 + +```json +"persona": "{{presetMap:persona_preset}}" +``` + +这类变量只在 import 后的 workspace recipe 里使用编译好的 map,不会在运行时继续去读外部 `assets/`。 + +## 12. `clawpalImport` 和 `assets/` + +如果 recipe 需要把外部 markdown 资产编译进最终 recipe,可以使用: + +```json +"clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "coach", "label": "Coach", "asset": "assets/personas/coach.md" }, + { "value": "researcher", "label": "Researcher", "asset": "assets/personas/researcher.md" } + ] + } +} +``` + +import 阶段会做三件事: +- 校验 `asset` 是否存在 +- 为目标 param 注入 `options` +- 把 `{{presetMap:param_id}}` 编译成内嵌文本映射 + +最终写入 workspace 的 recipe: +- 不再保留 `clawpalImport` +- 不再依赖原始 `assets/` 目录 +- 会带 `clawpalPresetMaps` + +## 13. `presentation` 怎么用 + +如果希望 `Done`、`Recent Recipe Runs`、`Orchestrator` 显示更业务化的结果,给 recipe 增加: + +```json +"presentation": { + "resultSummary": "Updated persona for agent {{agent_id}}" +} +``` + +原则: +- 写给非技术用户看 +- 描述“得到什么结果”,不要描述执行细节 +- 没写时会退回到通用 summary + +## 14. OpenClaw-first 原则 + +作者在写 Recipe 时要默认遵循: + +- 能用业务动作表达的,不要退回 `config_patch` +- 能用 OpenClaw 原语表达的,让 runner 优先走 OpenClaw +- 文档动作只在 OpenClaw 还没有对应原语时作为底座 + +例如: +- `set_channel_persona` 优于手写 `config_patch` +- `ensure_model_profile` 优于假定目标环境已经有 profile +- `upsert_markdown_document` 适合写 agent 默认 markdown 文档 + +更详细的边界见:[recipe-runner-boundaries.md](./recipe-runner-boundaries.md) + +## 15. 最小验证流程 + +新增或修改 recipe 后,至少做这几步: + +1. 校验 Rust 侧 recipe 测试 + +```bash +cargo test recipe_ --lib --manifest-path src-tauri/Cargo.toml +``` + +2. 校验前端类型和关键 UI + +```bash +bun run typecheck +``` + +3. 如改了导入规则或预置 recipe,验证 import/seed 结果 + +```bash +cargo test import_recipe_library_accepts_repo_example_library --manifest-path src-tauri/Cargo.toml +``` + +4. 如改了业务闭环,优先补 Docker OpenClaw e2e + +## 16. 常见坑 + +- `steps` 和 `actions` 数量不一致会直接校验失败 +- `Import` library 时,`recipe.json` 不能是数组 +- `upsert_markdown_document` 的 `upsertSection` 模式必须带 `heading` +- `target.scope=agent` 时必须带 `agentId` +- 相对路径里不允许 `..` +- destructive action 默认会被引用检查挡住 +- recipe 不能内嵌明文 secret;环境动作只能引用 ClawPal 已能解析到的 secret/auth + +如果你需要理解 runner 负责什么、不负责什么,再看:[recipe-runner-boundaries.md](./recipe-runner-boundaries.md) diff --git a/docs/recipe-cli-action-catalog.md b/docs/recipe-cli-action-catalog.md new file mode 100644 index 00000000..c0c00c4f --- /dev/null +++ b/docs/recipe-cli-action-catalog.md @@ -0,0 +1,114 @@ +# Recipe CLI Action Catalog + +这篇文档是 Recipe DSL 的高级参考,面向: +- 需要直接复用 OpenClaw CLI 原语的 recipe 作者 +- 维护 runner/action catalog 的平台开发者 + +普通业务 recipe 请先看:[recipe-authoring.md](./recipe-authoring.md)。 + +## 1. 设计规则 + +- 一个 CLI 原语动作尽量对应一个 OpenClaw CLI 子命令 +- `Runner supported = yes` 表示当前 Recipe runner 可以直接执行 +- `Runner supported = no` 表示该动作只记录在 catalog 中,当前不能由 Recipe runner 执行 +- `Recommended direct use = no` 表示虽然能执行,但更推荐用高层业务动作 + +## 2. Agents + +| DSL action | OpenClaw CLI | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `list_agents` | `openclaw agents list` | yes | no | 只读检查动作 | +| `list_agent_bindings` | `openclaw agents bindings` | yes | no | 只读检查动作 | +| `create_agent` | `openclaw agents add` | yes | yes | 推荐业务动作;runner 只会传入当前实例解析出的 OpenClaw 默认 workspace,不再使用 `agent_id` 这类自定义路径 | +| `delete_agent` | `openclaw agents delete` | yes | yes | 会先做 binding 引用检查 | +| `bind_agent` | `openclaw agents bind` | yes | yes | 推荐替代旧 `bind_channel` | +| `unbind_agent` | `openclaw agents unbind` | yes | yes | 支持 `binding` 或 `all=true` | +| `set_agent_identity` | `openclaw agents set-identity` | yes | yes | 推荐替代旧 `setup_identity` | + +## 3. Config + +| DSL action | OpenClaw CLI | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `show_config_file` | `openclaw config file` | yes | no | 只读检查动作 | +| `get_config_value` | `openclaw config get` | yes | no | 只读检查动作 | +| `set_config_value` | `openclaw config set` | yes | no | 可直接写值;大多数业务 recipe 优先用业务动作 | +| `unset_config_value` | `openclaw config unset` | yes | no | 同上 | +| `validate_config` | `openclaw config validate` | yes | no | 只读检查动作 | +| `config_patch` | 多条 `openclaw config set` | yes | no | escape hatch,不是 1:1 CLI 子命令 | + +## 4. Models + +| DSL action | OpenClaw CLI | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `models_status` | `openclaw models status` | yes | no | 支持 probe 相关 flags | +| `list_models` | `openclaw models list` | yes | no | 只读检查动作 | +| `set_default_model` | `openclaw models set` | yes | no | 会改默认模型,不会改指定 agent | +| `scan_models` | `openclaw models scan` | yes | no | 只读检查动作 | +| `list_model_aliases` | `openclaw models aliases list` | yes | no | 只读检查动作 | +| `list_model_fallbacks` | `openclaw models fallbacks list` | yes | no | 只读检查动作 | +| `add_model_auth_profile` | `openclaw models auth add` | no | no | provider-specific schema 还没收口 | +| `login_model_auth` | `openclaw models auth login` | no | no | interactive | +| `setup_model_auth_token` | `openclaw models auth setup-token` | no | no | interactive / token flow | +| `paste_model_auth_token` | `openclaw models auth paste-token` | no | no | 需要 secret payload,不应进 recipe source | +| `set_agent_model` | 编排动作 | yes | yes | 高层业务动作,优先使用 | +| `ensure_model_profile` | 编排动作 | yes | yes | 高层环境动作,优先使用 | +| `delete_model_profile` | 编排动作 | yes | yes | 高层环境动作,优先使用 | +| `ensure_provider_auth` | 编排动作 | yes | yes | 高层环境动作,优先使用 | +| `delete_provider_auth` | 编排动作 | yes | yes | 高层环境动作,优先使用 | + +## 5. Channels + +| DSL action | OpenClaw CLI | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `list_channels` | `openclaw channels list` | yes | no | 只读检查动作 | +| `channels_status` | `openclaw channels status` | yes | no | 只读检查动作 | +| `read_channel_logs` | `openclaw channels logs` | no | no | 目前还没定义稳定参数 schema | +| `add_channel_account` | `openclaw channels add` | no | no | provider-specific flags 太多,后续再抽象 | +| `remove_channel_account` | `openclaw channels remove` | no | no | 当前未抽象稳定 schema | +| `login_channel_account` | `openclaw channels login` | no | no | interactive | +| `logout_channel_account` | `openclaw channels logout` | no | no | interactive | +| `inspect_channel_capabilities` | `openclaw channels capabilities` | yes | no | 只读检查动作 | +| `resolve_channel_targets` | `openclaw channels resolve` | yes | no | 只读检查动作 | +| `set_channel_persona` | `openclaw config set` | yes | yes | 高层业务动作,优先使用 | +| `clear_channel_persona` | `openclaw config set` | yes | yes | 高层业务动作,优先使用 | + +## 6. Secrets + +| DSL action | OpenClaw CLI | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `reload_secrets` | `openclaw secrets reload` | yes | no | 只读/刷新动作 | +| `audit_secrets` | `openclaw secrets audit` | yes | no | 只读检查动作 | +| `configure_secrets` | `openclaw secrets configure` | no | no | interactive | +| `apply_secrets_plan` | `openclaw secrets apply --from ...` | yes | no | 高级动作,直接消费 plan 文件 | + +## 7. Fallback / Document + +这些动作不是 OpenClaw CLI 子命令,但仍然是 DSL 的正式组成部分: + +| DSL action | Backend | Runner supported | Recommended direct use | Notes | +| --- | --- | --- | --- | --- | +| `upsert_markdown_document` | ClawPal document writer | yes | no | 仅限文本/markdown | +| `delete_markdown_document` | ClawPal document writer | yes | no | 仅限文本/markdown | +| `set_agent_persona` | ClawPal document writer | yes | yes | 当前还没有 OpenClaw 原语,所以保留 fallback | +| `clear_agent_persona` | ClawPal document writer | yes | yes | 同上 | +| `setup_identity` | legacy compatibility | yes | no | 旧动作,保留兼容 | +| `bind_channel` | legacy compatibility | yes | no | 旧动作,保留兼容 | +| `unbind_channel` | legacy compatibility | yes | no | 旧动作,保留兼容 | + +## 8. 什么时候直接用 CLI 原语动作 + +适合直接用 CLI 原语动作的场景: +- 你要写只读检查 recipe +- 你要做平台维护/运维型 recipe +- 你明确需要 OpenClaw CLI 的精确语义 + +不适合的场景: +- 面向非技术用户的 bundled recipe +- 可以清楚表达成业务动作的配置改动 +- 需要携带 secret payload 的命令 +- interactive 命令 + +## 9. 相关文档 + +- 作者指南:[recipe-authoring.md](./recipe-authoring.md) +- Runner 边界:[recipe-runner-boundaries.md](./recipe-runner-boundaries.md) diff --git a/docs/recipe-runner-boundaries.md b/docs/recipe-runner-boundaries.md new file mode 100644 index 00000000..bb7ca357 --- /dev/null +++ b/docs/recipe-runner-boundaries.md @@ -0,0 +1,339 @@ +# Recipe Runner 的边界 + +这篇文档面向平台开发者,不面向普通 Recipe 使用者。 + +目标: +- 统一 `Recipe Source -> ExecutionSpec -> runner -> backend` 的分层理解 +- 明确 runner 应该负责什么、不应该负责什么 +- 约束何时新增业务动作,何时复用底座动作 + +## 1. 先定义 4 层 + +### Recipe Source + +也就是作者写的 `recipe.json`。 + +它负责表达: +- 用户要填写什么参数 +- 这条 recipe 想达成什么业务结果 +- 应该被编译成哪些 action +- 结果文案如何展示 + +它不负责: +- 目标环境上的具体命令行细节 +- 本地与远端执行差异 +- 执行顺序里的低层物化细节 + +### ExecutionSpec + +这是 Recipe DSL 的中间表示。 + +它负责表达: +- action 列表 +- capability 使用 +- resource claim +- execution kind +- source metadata + +它不负责: +- 直接执行命令 +- 直接做 UI copy + +### runner + +runner 是执行后端,不是通用脚本解释器。 + +它负责: +- 把 action 物化成 OpenClaw CLI、配置改写或内部底座命令 +- 按目标环境路由到 `local`、`docker_local`、`remote_ssh` +- 执行前做必要的引用检查、环境准备和 fallback +- 产出 runtime run、artifacts、warnings + +它不负责: +- 解释任意 shell 脚本 +- 执行未经白名单声明的新 action +- 作为通用文件管理器处理二进制资源 + +### backend + +backend 是 runner 最终调用的能力来源。 + +优先级固定为: +1. OpenClaw CLI / OpenClaw config 原语 +2. ClawPal 的受控内部底座能力 + +## 2. OpenClaw-first 原则 + +这是当前 runner 的首要设计原则: + +- 能用 OpenClaw 原语表达的动作,必须优先走 OpenClaw +- 只有 OpenClaw 暂时没有表达能力的资源,才允许 ClawPal fallback + +当前典型映射: +- `create_agent` -> OpenClaw CLI +- `bind_agent` / `unbind_agent` -> OpenClaw CLI +- `set_agent_identity` -> OpenClaw CLI +- `set_channel_persona` / `clear_channel_persona` -> OpenClaw config rewrite +- `ensure_model_profile` / `ensure_provider_auth` -> 复用现有 profile/auth 同步能力 +- `upsert_markdown_document` / `delete_markdown_document` -> ClawPal fallback +- `set_agent_persona` / `clear_agent_persona` -> 当前基于文档底座实现 + +这个原则的目的: +- 最大程度复用 OpenClaw +- 降低未来兼容性风险 +- 避免把 Recipe 系统做成第二套 OpenClaw 配置内核 + +对 `create_agent` 还有一条额外约束: +- workspace 策略由 OpenClaw 决定 +- 由于 `agents add --non-interactive` 需要显式 `--workspace`,runner 只会传入当前实例解析出的 OpenClaw 默认 workspace +- runner 不再为新 agent 推导 `--workspace ` 这类 ClawPal 自定义路径 +- 旧 source 里如果仍带 `independent`,当前只做兼容解析,不再影响 workspace 结果 + +## 3. 为什么不支持任意 shell + +runner 刻意不支持: +- 任意 shell action +- 任意脚本片段 +- 任意命令白名单外执行 + +原因很直接: +- 无法稳定推导 capability 和 resource claim +- 无法给非技术用户做可理解的 Review/Done 语义 +- 无法做合理的风险控制、回滚和审计 +- 会把 Recipe 降级成“远程脚本执行器” + +如果一个需求只能靠通用 shell 才能表达,优先问两个问题: +1. 这是不是应该先成为 OpenClaw 原语? +2. 这是不是应该先成为受控的业务动作或底座动作? + +## 4. action 白名单 + +当前 Recipe DSL 的 action surface 分两层主路径,再加两组底座/兼容动作。 + +### 推荐的业务动作 + +- `create_agent` +- `delete_agent` +- `bind_agent` +- `unbind_agent` +- `set_agent_identity` +- `set_agent_model` +- `set_agent_persona` +- `clear_agent_persona` +- `set_channel_persona` +- `clear_channel_persona` + +### CLI 原语动作 + +这层按 OpenClaw CLI 子命令 1:1 暴露,适合高级 recipe 或只读检查 recipe。 + +当前 catalog 覆盖: +- `agents` +- `config` +- `models` +- `channels` +- `secrets` + +例子: +- `list_agents` +- `show_config_file` +- `get_config_value` +- `models_status` +- `list_channels` +- `audit_secrets` + +完整列表见:[recipe-cli-action-catalog.md](./recipe-cli-action-catalog.md) + +### 文档动作 + +- `upsert_markdown_document` +- `delete_markdown_document` + +### 环境动作 + +- `ensure_model_profile` +- `delete_model_profile` +- `ensure_provider_auth` +- `delete_provider_auth` + +### 兼容 / escape hatch + +- `config_patch` +- `setup_identity` +- `bind_channel` +- `unbind_channel` + +新增 action 之前,先确认它不能被: +- 推荐的业务动作 +- CLI 原语动作 +- 文档动作 +- 环境动作 +合理表达。 + +## 5. 什么时候新增业务动作 + +优先新增业务动作,而不是继续堆 `config_patch`,当且仅当: + +- 这个意图会反复出现在用户故事里 +- 它对非技术用户来说有清晰结果语义 +- 它值得单独审计、单独展示 Review/Done copy +- 它对应的 capability / claim 可以稳定推导 + +例如: +- `set_channel_persona` 比直接写 `config_patch` 更合适 +- `set_agent_model` 比让 recipe 自己拼 config path 更合适 +- `set_agent_identity` 比继续依赖 legacy `setup_identity` 更合适 + +## 6. 什么时候复用文档动作 + +优先复用 `upsert_markdown_document` / `delete_markdown_document`,当: + +- 目标是文本/markdown 资源 +- OpenClaw 暂时没有专门原语 +- 需要 whole-file replace 或 section upsert +- 需要 local / remote 上一致的路径解析与写入语义 + +当前文档动作的目标范围是: +- `scope=agent` +- `scope=home` +- `scope=absolute` + +但仍有限制: +- 只处理文本/markdown +- 相对路径里禁止 `..` +- `scope=agent` 必须能解析到合法 agent 文档目录 + +## 7. destructive 动作的默认阻断 + +第一阶段就支持 destructive action,但默认是保守的。 + +### `delete_agent` + +默认会检查该 agent 是否仍被 channel binding 引用。 + +如果仍被引用: +- 默认失败 +- 显式 `force=true` 或 `rebindChannelsTo` 才允许继续 + +### `delete_model_profile` + +默认会检查该 profile 是否仍被 model binding 引用。 + +如果仍被引用: +- 默认失败 + +### `delete_provider_auth` + +默认会检查该 authRef 是否仍被 model binding 间接使用。 + +如果仍被引用: +- 默认失败 +- 显式 `force=true` 才允许继续 + +这套规则的目标不是“禁止删除”,而是让 destructive 行为必须有明确意图。 + +## 8. secret 与环境动作的边界 + +Recipe 不应携带明文 secret。 + +环境动作的原则: +- Recipe 只能引用现有 profile/auth/provider 关系 +- 如果目标环境缺少依赖,runner 可以同步 ClawPal 已能解析到的 secret/auth +- secret 本体不应出现在 recipe params 或 source 里 + +换句话说: +- `ensure_model_profile` 可以触发 profile + auth 的准备 +- 但 recipe source 自己不应成为 secret 载体 + +## 8.1 信任与批准不属于 runner 的“可选增强” + +当前平台把来源信任和批准当成执行边界,而不是单纯 UI 提示。 + +来源分级: + +- `bundled` +- `localImport` +- `remoteUrl` + +runner / command layer 必须配合上层保证: + +- 高风险 bundled recipe 未批准时不能执行 +- 本地导入 recipe 在需要批准时不能执行 +- 远程 URL recipe 的 mutating 行为未批准时不能执行 + +批准绑定到 `workspace slug + recipe digest`: + +- digest 不变,批准可复用 +- digest 变化,批准立即失效 + +这也是为什么 bundled recipe 升级不能静默覆盖: + +- 一旦 source 变化,之前的批准就不再可信 +- 用户需要明确看见新版本,并重新决定是否接受 + +## 9. Review / Done 为什么要依赖 action 语义 + +当前 UI 面向非技术用户,因此: +- Review 要展示“会得到什么结果” +- Done 要展示“已经完成了什么” +- Orchestrator 要展示“最近发生了什么效果” + +如果 action 只有低层技术含义,例如裸 `config_patch`,UI 就只能暴露路径和技术细节。 + +因此,业务动作的价值不仅是执行方便,更是: +- 可翻译成自然语言 +- 可推导影响对象 +- 可生成稳定的结果文案 + +## 10. 何时应该修改 OpenClaw,而不是扩 runner + +当一个需求满足下面任意一条时,应优先考虑给 OpenClaw 增加原语,而不是在 runner 里继续堆 fallback: + +- 它已经是 OpenClaw 的核心资源模型 +- 它需要长期稳定的 CLI/配置兼容承诺 +- 它不是单纯的文本资源写入 +- 它跨多个客户端都应该共享同一套语义 + +runner 适合作为: +- OpenClaw 原语的编排层 +- OpenClaw 暂时缺位时的受控 fallback + +runner 不适合作为: +- 一套长期独立于 OpenClaw 的第二执行内核 + +## 11. 设计新增 action 的最小检查表 + +新增一个 action 前,至少回答这几个问题: + +1. 这个动作是业务动作、文档动作,还是环境动作? +2. 能否直接复用已有 action? +3. 能否优先映射到 OpenClaw? +4. 它需要哪些 capability? +5. 它会触碰哪些 resource claim? +6. 它是否是 destructive? +7. 它的 Review copy 和 Done copy 应该怎么表达? +8. 它是否需要默认阻断或引用检查? + +如果这些问题答不清楚,不要先写 runner。 + +## 12. 关于 CLI 原语动作的边界 + +不是每个出现在 OpenClaw CLI 文档里的子命令,都适合直接由 Recipe runner 执行。 + +当前 catalog 会把它们分成两类: +- `runner supported = yes` +- `runner supported = no` + +典型不能直接执行的情况: +- interactive 命令 +- 需要明文 token / secret payload 的命令 +- provider-specific flags 还没有稳定 schema 的命令 + +这些命令仍然会记录在 catalog 里,原因是: +- 文档和实现保持同一个事实源 +- 作者能明确知道“这个 CLI 子命令存在,但当前不能写进 recipe” + +## 13. 相关文档 + +- 作者指南:[recipe-authoring.md](./recipe-authoring.md) +- CLI catalog:[recipe-cli-action-catalog.md](./recipe-cli-action-catalog.md) diff --git a/docs/testing/local-docker-openclaw-debug.md b/docs/testing/local-docker-openclaw-debug.md new file mode 100644 index 00000000..39144835 --- /dev/null +++ b/docs/testing/local-docker-openclaw-debug.md @@ -0,0 +1,276 @@ +# Local Docker OpenClaw Debug Environment + +## Goal + +Use a disposable Ubuntu container as an isolated OpenClaw target for ClawPal recipe testing. + +This keeps recipe validation away from your host `~/.openclaw` and away from production VPS instances. + +## What this environment contains + +- A fresh `ubuntu:22.04` container +- SSH exposed on `127.0.0.1:2299` +- OpenClaw installed via the official installer +- A minimal OpenClaw config that ClawPal can discover +- One baseline agent: `main` +- One baseline model: `openai/gpt-4o` +- One Discord fixture: + - `guild-recipe-lab` + - `channel-general` + - `channel-support` + +Recommended remote instance settings inside ClawPal: + +- Label: `Local Remote SSH` +- Host: `127.0.0.1` +- Port: `2299` +- Username: `root` +- Password: `clawpal-recipe-pass` + +## Important rule + +Do not keep ClawPal connected to the container while OpenClaw is still being installed or seeded. + +ClawPal may probe the remote host, detect that `openclaw` is missing, and trigger overlapping auto-install flows. That can leave `apt`/`dpkg` locked inside the container and make the bootstrap flaky. + +Safe sequence: + +1. Build the container. +2. Install and seed OpenClaw. +3. Verify the remote CLI works over SSH. +4. Only then launch `bun run dev:tauri` and connect ClawPal. + +## Rebuild from scratch + +### 1. Remove any previous test containers + +```bash +docker rm -f clawpal-recipe-test-ubuntu-openclaw sweet_jang +``` + +`sweet_jang` was a previously reused image/container in local debugging. Remove it too so the new environment starts from a clean Ubuntu base. + +### 2. Start a fresh Ubuntu container + +```bash +docker run -d \ + --name clawpal-recipe-test-ubuntu-openclaw \ + -p 2299:22 \ + -p 18799:18789 \ + ubuntu:22.04 \ + sleep infinity +``` + +### 3. Install SSH and base packages + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw apt-get update +docker exec clawpal-recipe-test-ubuntu-openclaw apt-get install -y \ + openssh-server curl ca-certificates git xz-utils jq +``` + +### 4. Enable root password login for local debugging + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw sh -lc ' + echo "root:clawpal-recipe-pass" | chpasswd && + mkdir -p /run/sshd && + sed -i "s/^#\\?PermitRootLogin .*/PermitRootLogin yes/" /etc/ssh/sshd_config && + sed -i "s/^#\\?PasswordAuthentication .*/PasswordAuthentication yes/" /etc/ssh/sshd_config && + /usr/sbin/sshd +' +``` + +### 5. Install OpenClaw + +Use the official installer: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw sh -lc ' + curl -fsSL --proto "=https" --tlsv1.2 https://openclaw.ai/install.sh | \ + bash -s -- --no-prompt --no-onboard +' +``` + +Expected check: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw openclaw --version +``` + +## Seed the minimal test fixture + +### 6. Bootstrap the config file with the OpenClaw CLI + +Create `~/.openclaw/openclaw.json` through OpenClaw itself: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw \ + openclaw config set gateway.port 18789 --strict-json +``` + +Seed a minimal provider catalog: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw sh -lc ' + openclaw config set models.providers \ + "{\"openai\":{\"baseUrl\":\"https://api.openai.com/v1\",\"models\":[{\"id\":\"gpt-4o\",\"name\":\"GPT-4o\"}]}}" \ + --strict-json +' +``` + +Set the default model: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw \ + openclaw models set openai/gpt-4o +``` + +### 7. Seed the default agent identity with the OpenClaw CLI + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw \ + openclaw agents set-identity \ + --agent main \ + --name "Main Agent" \ + --emoji "🤖" \ + --json +``` + +### 8. Seed Discord test channels with the OpenClaw CLI + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw sh -lc ' + openclaw config set channels.discord \ + "{\"guilds\":{\"guild-recipe-lab\":{\"channels\":{\"channel-general\":{\"systemPrompt\":\"\"},\"channel-support\":{\"systemPrompt\":\"\"}}}}}" \ + --strict-json +' +``` + +### 9. Seed a test auth profile + +Current boundary: this part is still a controlled file seed, not a pure OpenClaw CLI flow. + +Reason: + +- `openclaw models auth paste-token` is interactive +- the current local recipe/debug flow needs a non-interactive baseline credential + +Until OpenClaw exposes a stable non-interactive auth seed command, use: + +```bash +docker exec clawpal-recipe-test-ubuntu-openclaw sh -lc ' + mkdir -p /root/.openclaw/agents/main/agent && + cat > /root/.openclaw/agents/main/agent/auth-profiles.json <<\"EOF\" +{"version":1,"profiles":{"openai:default":{"type":"api_key","provider":"openai","secretRef":{"source":"env","id":"OPENAI_API_KEY"}}}} +EOF + printf "export OPENAI_API_KEY=test-openai-key\n" >> /root/.profile + printf "export OPENAI_API_KEY=test-openai-key\n" >> /root/.bash_profile +' +``` + +This is the one intentional exception to the `OpenClaw-first` rule for this local debug fixture. + +## Verify the container before opening ClawPal + +### 10. Verify over SSH + +Agent list: + +```bash +expect -c 'set timeout 20; \ + spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2299 root@127.0.0.1 openclaw agents list --json; \ + expect "password:"; \ + send "clawpal-recipe-pass\r"; \ + expect eof' +``` + +Discord fixture: + +```bash +expect -c 'set timeout 20; \ + spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2299 root@127.0.0.1 openclaw config get channels.discord --json; \ + expect "password:"; \ + send "clawpal-recipe-pass\r"; \ + expect eof' +``` + +You should see: + +- `main` as the default agent +- `openai/gpt-4o` as the model +- `guild-recipe-lab` +- `channel-general` +- `channel-support` + +## Use it inside ClawPal + +Once the checks above pass: + +1. Start ClawPal: + ```bash + bun run dev:tauri + ``` +2. Add or reuse the remote SSH instance: + - Host: `127.0.0.1` + - Port: `2299` + - User: `root` + - Password: `clawpal-recipe-pass` +3. Open `Recipes` +4. Use the bundled recipes against this isolated target + +## What this fixture is good for + +- `Dedicated Agent` +- `Agent Persona Pack` +- `Channel Persona Pack` +- Review/Execute/Done UX +- remote discovery for: + - agents + - guilds/channels + - remote config snapshots + - recipe runtime writes + +## Troubleshooting + +### Agent or guild dropdowns are empty + +Check these two commands first: + +```bash +ssh -p 2299 root@127.0.0.1 openclaw agents list --json +ssh -p 2299 root@127.0.0.1 openclaw config get channels.discord --json +``` + +If either fails, fix the container before debugging the UI. + +### OpenClaw installer hangs or apt is locked + +Likely cause: ClawPal connected too early and triggered an overlapping auto-install attempt. + +Recovery: + +1. Stop ClawPal. +2. Stop `sshd` in the container. +3. Kill leftover installer processes. +4. Run `dpkg --configure -a`. +5. Retry the OpenClaw install once. + +### Docker daemon itself becomes unhealthy + +If `docker version` hangs or returns socket errors: + +1. Restart Docker Desktop. +2. Confirm `docker version` works. +3. Rebuild the container from scratch. + +## Maintenance note + +Keep this local debug fixture aligned with the Docker E2E path in: + +- [recipe_docker_e2e.rs](../../src-tauri/tests/recipe_docker_e2e.rs) + +If the required OpenClaw schema changes, update both: + +- the local debug fixture in this document +- the E2E fixture and assertions diff --git a/examples/recipe-library/agent-persona-pack/assets/personas/coach.md b/examples/recipe-library/agent-persona-pack/assets/personas/coach.md new file mode 100644 index 00000000..a26db25c --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/assets/personas/coach.md @@ -0,0 +1,3 @@ +You are a focused coaching agent. + +Help the team make progress with short, direct guidance. Push for clarity, prioritization, and next actions. diff --git a/examples/recipe-library/agent-persona-pack/assets/personas/friendly-guide.md b/examples/recipe-library/agent-persona-pack/assets/personas/friendly-guide.md new file mode 100644 index 00000000..f3145587 --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/assets/personas/friendly-guide.md @@ -0,0 +1,5 @@ +You are a friendly guide for this agent. + +- Be warm and concise. +- Prefer practical next steps. +- Explain tradeoffs without lecturing. diff --git a/examples/recipe-library/agent-persona-pack/assets/personas/incident-commander.md b/examples/recipe-library/agent-persona-pack/assets/personas/incident-commander.md new file mode 100644 index 00000000..4f60fa0e --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/assets/personas/incident-commander.md @@ -0,0 +1,5 @@ +You are the incident commander persona for this agent. + +- Keep updates crisp and operational. +- Call out risk, owner, and next checkpoint. +- Prefer coordination and clear delegation over brainstorming. diff --git a/examples/recipe-library/agent-persona-pack/assets/personas/researcher.md b/examples/recipe-library/agent-persona-pack/assets/personas/researcher.md new file mode 100644 index 00000000..8a4c097b --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/assets/personas/researcher.md @@ -0,0 +1,3 @@ +You are a careful research agent. + +Gather context before making recommendations. Highlight assumptions, tradeoffs, and unknowns. diff --git a/examples/recipe-library/agent-persona-pack/assets/personas/reviewer.md b/examples/recipe-library/agent-persona-pack/assets/personas/reviewer.md new file mode 100644 index 00000000..12b5e9a1 --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/assets/personas/reviewer.md @@ -0,0 +1,3 @@ +You are a sharp reviewer. + +You inspect plans for weak assumptions, missing safeguards, and operational blind spots. diff --git a/examples/recipe-library/agent-persona-pack/recipe.json b/examples/recipe-library/agent-persona-pack/recipe.json new file mode 100644 index 00000000..6373289f --- /dev/null +++ b/examples/recipe-library/agent-persona-pack/recipe.json @@ -0,0 +1,92 @@ +{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Import a preset persona into an existing agent", + "version": "1.0.0", + "tags": ["agent", "persona", "preset"], + "difficulty": "easy", + "presentation": { + "resultSummary": "Updated persona for agent {{agent_id}}" + }, + "params": [ + { "id": "agent_id", "label": "Agent", "type": "agent", "required": true }, + { "id": "persona_preset", "label": "Persona Preset", "type": "string", "required": true, "placeholder": "Select a preset" } + ], + "steps": [ + { + "action": "set_agent_persona", + "label": "Apply agent persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "agent-persona-pack", + "version": "1.0.0", + "description": "Import a preset persona into an existing agent" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.identity.write"] + }, + "resources": { + "supportedKinds": ["agent"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "agent-persona-pack" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "agent-persona-pack" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": ["agent.identity.write"] + }, + "resources": { + "claims": [ + { "kind": "agent", "id": "{{agent_id}}" } + ] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 1 + }, + "actions": [ + { + "kind": "set_agent_persona", + "name": "Apply agent persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "agent-persona-pack" }] + }, + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "coach", "label": "Coach", "asset": "assets/personas/coach.md" }, + { "value": "researcher", "label": "Researcher", "asset": "assets/personas/researcher.md" } + ] + } + } +} diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/community-host.md b/examples/recipe-library/channel-persona-pack/assets/personas/community-host.md new file mode 100644 index 00000000..1acdb449 --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/community-host.md @@ -0,0 +1,5 @@ +You are the community host persona for this Discord channel. + +- Keep the room welcoming and clear. +- Encourage the next useful action. +- Be upbeat without becoming noisy. diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/concise.md b/examples/recipe-library/channel-persona-pack/assets/personas/concise.md new file mode 100644 index 00000000..415b2f5a --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/concise.md @@ -0,0 +1,3 @@ +You are concise and execution-focused. + +Answer with short, direct guidance and end with the next concrete action. diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/incident.md b/examples/recipe-library/channel-persona-pack/assets/personas/incident.md new file mode 100644 index 00000000..bb980997 --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/incident.md @@ -0,0 +1,3 @@ +You are the incident commander for this channel. + +Drive fast triage, assign owners, summarize status, and keep messages crisp under pressure. diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/ops-briefing.md b/examples/recipe-library/channel-persona-pack/assets/personas/ops-briefing.md new file mode 100644 index 00000000..7f47430d --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/ops-briefing.md @@ -0,0 +1,5 @@ +You are the operations briefing persona for this Discord channel. + +- Keep messages direct and actionable. +- Prefer status, impact, owner, and next action. +- Avoid decorative language. diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/ops.md b/examples/recipe-library/channel-persona-pack/assets/personas/ops.md new file mode 100644 index 00000000..8a129bbc --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/ops.md @@ -0,0 +1,3 @@ +You are the operations coordinator for this channel. + +Prioritize incident clarity, next actions, owners, and status updates. diff --git a/examples/recipe-library/channel-persona-pack/assets/personas/support.md b/examples/recipe-library/channel-persona-pack/assets/personas/support.md new file mode 100644 index 00000000..db05dcf3 --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/assets/personas/support.md @@ -0,0 +1,3 @@ +You are the support concierge for this channel. + +Welcome users, ask clarifying questions, and turn vague requests into clean next steps. diff --git a/examples/recipe-library/channel-persona-pack/recipe.json b/examples/recipe-library/channel-persona-pack/recipe.json new file mode 100644 index 00000000..867dc9e1 --- /dev/null +++ b/examples/recipe-library/channel-persona-pack/recipe.json @@ -0,0 +1,97 @@ +{ + "id": "channel-persona-pack", + "name": "Channel Persona Pack", + "description": "Import a preset persona into a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona", "preset"], + "difficulty": "easy", + "presentation": { + "resultSummary": "Updated persona for channel {{channel_id}}" + }, + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "persona_preset", "label": "Persona Preset", "type": "string", "required": true, "placeholder": "Select a preset" } + ], + "steps": [ + { + "action": "set_channel_persona", + "label": "Apply channel persona preset", + "args": { + "channelType": "discord", + "guildId": "{{guild_id}}", + "peerId": "{{channel_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "channel-persona-pack", + "version": "1.0.0", + "description": "Import a preset persona into a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["config.write"] + }, + "resources": { + "supportedKinds": ["channel"] + }, + "execution": { + "supportedKinds": ["attachment"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "channel-persona-pack" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "channel-persona-pack" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "attachment" + }, + "capabilities": { + "usedCapabilities": ["config.write"] + }, + "resources": { + "claims": [ + { "kind": "channel", "id": "{{channel_id}}" } + ] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 1 + }, + "actions": [ + { + "kind": "set_channel_persona", + "name": "Apply channel persona preset", + "args": { + "channelType": "discord", + "guildId": "{{guild_id}}", + "peerId": "{{channel_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "channel-persona-pack" }] + }, + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "incident", "label": "Incident Commander", "asset": "assets/personas/incident.md" }, + { "value": "support", "label": "Support Concierge", "asset": "assets/personas/support.md" } + ] + } + } +} diff --git a/examples/recipe-library/dedicated-agent/recipe.json b/examples/recipe-library/dedicated-agent/recipe.json new file mode 100644 index 00000000..4935db6a --- /dev/null +++ b/examples/recipe-library/dedicated-agent/recipe.json @@ -0,0 +1,136 @@ +{ + "id": "dedicated-agent", + "name": "Dedicated Agent", + "description": "Create an agent and set its identity and persona", + "version": "1.0.0", + "tags": ["agent", "identity", "persona"], + "difficulty": "easy", + "presentation": { + "resultSummary": "Created dedicated agent {{name}} ({{agent_id}})" + }, + "params": [ + { "id": "agent_id", "label": "Agent ID", "type": "string", "required": true, "placeholder": "e.g. ops-bot" }, + { "id": "model", "label": "Model", "type": "model_profile", "required": true, "defaultValue": "__default__" }, + { "id": "name", "label": "Display Name", "type": "string", "required": true, "placeholder": "e.g. Ops Bot" }, + { "id": "emoji", "label": "Emoji", "type": "string", "required": false, "placeholder": "e.g. :satellite:" }, + { "id": "persona", "label": "Persona", "type": "textarea", "required": true, "placeholder": "Describe the role and tone for this agent." } + ], + "steps": [ + { + "action": "ensure_model_profile", + "label": "Prepare model access", + "args": { + "profileId": "{{model}}" + } + }, + { + "action": "create_agent", + "label": "Create dedicated agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}" + } + }, + { + "action": "set_agent_identity", + "label": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "action": "set_agent_persona", + "label": "Set agent persona", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{persona}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "dedicated-agent", + "version": "1.0.0", + "description": "Create a dedicated agent" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.manage", "agent.identity.write", "model.manage", "secret.sync"] + }, + "resources": { + "supportedKinds": ["agent", "modelProfile"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-agent" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "dedicated-agent" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": ["model.manage", "secret.sync", "agent.manage", "agent.identity.write"] + }, + "resources": { + "claims": [ + { "kind": "modelProfile", "id": "{{model}}" }, + { "kind": "agent", "id": "{{agent_id}}" } + ] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 4 + }, + "actions": [ + { + "kind": "ensure_model_profile", + "name": "Prepare model access", + "args": { + "profileId": "{{model}}" + } + }, + { + "kind": "create_agent", + "name": "Create dedicated agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}" + } + }, + { + "kind": "set_agent_identity", + "name": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "kind": "set_agent_persona", + "name": "Set agent persona", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{persona}}" + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-agent" }] + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bff4fd99..7a158cf3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,9 +15,11 @@ regex = "1.10.6" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0.214", features = ["derive"] } serde_json = "1.0.133" +serde_yaml = "0.9" tauri = { version = "2.1.0", features = [] } +tauri-plugin-dialog = "2" thiserror = "1.0.63" -uuid = { version = "1.11.0", features = ["v4"] } +uuid = { version = "1.11.0", features = ["v4", "v5"] } chrono = { version = "0.4.38", features = ["clock"] } base64 = "0.22" ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 9fe0775d..e616db12 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"dialog":{"default_permission":{"identifier":"default","description":"This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n","permissions":["allow-ask","allow-confirm","allow-message","allow-save","allow-open"]},"permissions":{"allow-ask":{"identifier":"allow-ask","description":"Enables the ask command without any pre-configured scope.","commands":{"allow":["ask"],"deny":[]}},"allow-confirm":{"identifier":"allow-confirm","description":"Enables the confirm command without any pre-configured scope.","commands":{"allow":["confirm"],"deny":[]}},"allow-message":{"identifier":"allow-message","description":"Enables the message command without any pre-configured scope.","commands":{"allow":["message"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-save":{"identifier":"allow-save","description":"Enables the save command without any pre-configured scope.","commands":{"allow":["save"],"deny":[]}},"deny-ask":{"identifier":"deny-ask","description":"Denies the ask command without any pre-configured scope.","commands":{"allow":[],"deny":["ask"]}},"deny-confirm":{"identifier":"deny-confirm","description":"Denies the confirm command without any pre-configured scope.","commands":{"allow":[],"deny":["confirm"]}},"deny-message":{"identifier":"deny-message","description":"Denies the message command without any pre-configured scope.","commands":{"allow":[],"deny":["message"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-save":{"identifier":"deny-save","description":"Denies the save command without any pre-configured scope.","commands":{"allow":[],"deny":["save"]}}},"permission_sets":{},"global_scope_schema":null},"process":{"default_permission":{"identifier":"default","description":"This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n","permissions":["allow-exit","allow-restart"]},"permissions":{"allow-exit":{"identifier":"allow-exit","description":"Enables the exit command without any pre-configured scope.","commands":{"allow":["exit"],"deny":[]}},"allow-restart":{"identifier":"allow-restart","description":"Enables the restart command without any pre-configured scope.","commands":{"allow":["restart"],"deny":[]}},"deny-exit":{"identifier":"deny-exit","description":"Denies the exit command without any pre-configured scope.","commands":{"allow":[],"deny":["exit"]}},"deny-restart":{"identifier":"deny-restart","description":"Denies the restart command without any pre-configured scope.","commands":{"allow":[],"deny":["restart"]}}},"permission_sets":{},"global_scope_schema":null},"updater":{"default_permission":{"identifier":"default","description":"This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n","permissions":["allow-check","allow-download","allow-install","allow-download-and-install"]},"permissions":{"allow-check":{"identifier":"allow-check","description":"Enables the check command without any pre-configured scope.","commands":{"allow":["check"],"deny":[]}},"allow-download":{"identifier":"allow-download","description":"Enables the download command without any pre-configured scope.","commands":{"allow":["download"],"deny":[]}},"allow-download-and-install":{"identifier":"allow-download-and-install","description":"Enables the download_and_install command without any pre-configured scope.","commands":{"allow":["download_and_install"],"deny":[]}},"allow-install":{"identifier":"allow-install","description":"Enables the install command without any pre-configured scope.","commands":{"allow":["install"],"deny":[]}},"deny-check":{"identifier":"deny-check","description":"Denies the check command without any pre-configured scope.","commands":{"allow":[],"deny":["check"]}},"deny-download":{"identifier":"deny-download","description":"Denies the download command without any pre-configured scope.","commands":{"allow":[],"deny":["download"]}},"deny-download-and-install":{"identifier":"deny-download-and-install","description":"Denies the download_and_install command without any pre-configured scope.","commands":{"allow":[],"deny":["download_and_install"]}},"deny-install":{"identifier":"deny-install","description":"Denies the install command without any pre-configured scope.","commands":{"allow":[],"deny":["install"]}}},"permission_sets":{},"global_scope_schema":null}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index 17e4a752..e9e12cb0 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -2144,6 +2144,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope." + }, + { + "description": "Enables the confirm command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope." + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope." + }, + { + "description": "Denies the confirm command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope." + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`", "type": "string", diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json index 17e4a752..e9e12cb0 100644 --- a/src-tauri/gen/schemas/macOS-schema.json +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -2144,6 +2144,72 @@ "const": "core:window:deny-unminimize", "markdownDescription": "Denies the unminimize command without any pre-configured scope." }, + { + "description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`", + "type": "string", + "const": "dialog:default", + "markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`" + }, + { + "description": "Enables the ask command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-ask", + "markdownDescription": "Enables the ask command without any pre-configured scope." + }, + { + "description": "Enables the confirm command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-confirm", + "markdownDescription": "Enables the confirm command without any pre-configured scope." + }, + { + "description": "Enables the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-message", + "markdownDescription": "Enables the message command without any pre-configured scope." + }, + { + "description": "Enables the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-open", + "markdownDescription": "Enables the open command without any pre-configured scope." + }, + { + "description": "Enables the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:allow-save", + "markdownDescription": "Enables the save command without any pre-configured scope." + }, + { + "description": "Denies the ask command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-ask", + "markdownDescription": "Denies the ask command without any pre-configured scope." + }, + { + "description": "Denies the confirm command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-confirm", + "markdownDescription": "Denies the confirm command without any pre-configured scope." + }, + { + "description": "Denies the message command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-message", + "markdownDescription": "Denies the message command without any pre-configured scope." + }, + { + "description": "Denies the open command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-open", + "markdownDescription": "Denies the open command without any pre-configured scope." + }, + { + "description": "Denies the save command without any pre-configured scope.", + "type": "string", + "const": "dialog:deny-save", + "markdownDescription": "Denies the save command without any pre-configured scope." + }, { "description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`", "type": "string", diff --git a/src-tauri/recipes.json b/src-tauri/recipes.json index 380ba777..b0e8fe77 100644 --- a/src-tauri/recipes.json +++ b/src-tauri/recipes.json @@ -1,44 +1,3 @@ { - "recipes": [ - { - "id": "dedicated-channel-agent", - "name": "Create dedicated Agent for Channel", - "description": "Create an agent, optionally independent with its own identity and persona, and bind it to a Discord channel", - "version": "1.0.0", - "tags": ["discord", "agent", "persona"], - "difficulty": "easy", - "params": [ - { "id": "agent_id", "label": "Agent ID", "type": "string", "required": true, "placeholder": "e.g. my-bot" }, - { "id": "model", "label": "Model", "type": "model_profile", "required": true, "defaultValue": "__default__" }, - { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, - { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, - { "id": "independent", "label": "Create independent agent", "type": "boolean", "required": false }, - { "id": "name", "label": "Display Name", "type": "string", "required": false, "placeholder": "e.g. MyBot", "dependsOn": "independent" }, - { "id": "emoji", "label": "Emoji", "type": "string", "required": false, "placeholder": "e.g. \ud83e\udd16", "dependsOn": "independent" }, - { "id": "persona", "label": "Persona", "type": "textarea", "required": false, "placeholder": "You are...", "dependsOn": "independent" } - ], - "steps": [ - { "action": "create_agent", "label": "Create agent", "args": { "agentId": "{{agent_id}}", "modelProfileId": "{{model}}", "independent": "{{independent}}" } }, - { "action": "setup_identity", "label": "Set agent identity", "args": { "agentId": "{{agent_id}}", "name": "{{name}}", "emoji": "{{emoji}}" } }, - { "action": "bind_channel", "label": "Bind channel to agent", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "agentId": "{{agent_id}}" } }, - { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } - ] - }, - { - "id": "discord-channel-persona", - "name": "Channel Persona", - "description": "Set a custom persona for a Discord channel", - "version": "1.0.0", - "tags": ["discord", "persona", "beginner"], - "difficulty": "easy", - "params": [ - { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, - { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, - { "id": "persona", "label": "Persona", "type": "textarea", "required": true, "placeholder": "You are..." } - ], - "steps": [ - { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } - ] - } - ] + "recipes": [] } diff --git a/src-tauri/src/agent_identity.rs b/src-tauri/src/agent_identity.rs new file mode 100644 index 00000000..8145007c --- /dev/null +++ b/src-tauri/src/agent_identity.rs @@ -0,0 +1,910 @@ +use std::fs; +use std::path::PathBuf; + +use serde_json::Value; + +use crate::config_io::read_openclaw_config; +use crate::models::OpenClawPaths; +use crate::ssh::SshConnectionPool; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct IdentityDocument { + name: Option, + emoji: Option, + persona: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PersonaChange<'a> { + Preserve, + Set(&'a str), + Clear, +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn parse_identity_content(text: &str) -> IdentityDocument { + let mut result = IdentityDocument::default(); + let normalized = text.replace("\r\n", "\n"); + let mut sections = normalized.splitn(2, "\n## Persona\n"); + let header = sections.next().unwrap_or_default(); + let persona = sections.next().map(|value| value.trim_end_matches('\n')); + + for line in header.lines() { + if let Some(name) = line.strip_prefix("- Name:") { + result.name = normalize_optional_text(Some(name)); + } else if let Some(emoji) = line.strip_prefix("- Emoji:") { + result.emoji = normalize_optional_text(Some(emoji)); + } + } + + result.persona = normalize_optional_text(persona); + result +} + +fn merge_identity_document( + existing: Option<&str>, + default_name: Option<&str>, + default_emoji: Option<&str>, + name: Option<&str>, + emoji: Option<&str>, + persona: PersonaChange<'_>, +) -> Result { + let existing = existing.map(parse_identity_content).unwrap_or_default(); + let name = normalize_optional_text(name) + .or(existing.name.clone()) + .or(normalize_optional_text(default_name)); + let emoji = normalize_optional_text(emoji) + .or(existing.emoji.clone()) + .or(normalize_optional_text(default_emoji)); + let persona = match persona { + PersonaChange::Preserve => existing.persona.clone(), + PersonaChange::Set(persona) => { + normalize_optional_text(Some(persona)).or(existing.persona.clone()) + } + PersonaChange::Clear => None, + }; + + let Some(name) = name else { + return Err( + "agent identity requires a name when no existing IDENTITY.md is present".into(), + ); + }; + + Ok(IdentityDocument { + name: Some(name), + emoji, + persona, + }) +} + +fn identity_content( + existing: Option<&str>, + default_name: Option<&str>, + default_emoji: Option<&str>, + name: Option<&str>, + emoji: Option<&str>, + persona: PersonaChange<'_>, +) -> Result { + let merged = + merge_identity_document(existing, default_name, default_emoji, name, emoji, persona)?; + let mut content = format!( + "- Name: {}\n", + merged.name.as_deref().unwrap_or_default().trim() + ); + if let Some(emoji) = merged.emoji.as_deref() { + content.push_str(&format!("- Emoji: {}\n", emoji)); + } + if let Some(persona) = merged.persona.as_deref() { + content.push_str("\n## Persona\n"); + content.push_str(persona); + content.push('\n'); + } + Ok(content) +} + +fn upsert_persona_content( + existing: Option<&str>, + explicit_name: Option<&str>, + explicit_emoji: Option<&str>, + default_name: Option<&str>, + default_emoji: Option<&str>, + persona: PersonaChange<'_>, +) -> Result { + match existing { + Some(existing_text) => { + let parsed = parse_identity_content(existing_text); + let has_structured_identity = parsed.name.is_some() || parsed.emoji.is_some(); + if !has_structured_identity + && (normalize_optional_text(explicit_name).is_some() + || normalize_optional_text(explicit_emoji).is_some()) + { + return identity_content( + None, + default_name, + default_emoji, + explicit_name, + explicit_emoji, + persona, + ); + } + Ok(match persona { + PersonaChange::Preserve => existing_text.to_string(), + PersonaChange::Set(persona_text) => { + crate::markdown_document::upsert_markdown_section( + existing_text, + "Persona", + persona_text, + ) + } + PersonaChange::Clear => { + crate::markdown_document::upsert_markdown_section(existing_text, "Persona", "") + } + }) + } + None => identity_content( + existing, + default_name, + default_emoji, + explicit_name, + explicit_emoji, + persona, + ), + } +} + +fn resolve_workspace( + cfg: &Value, + agent_id: &str, + default_workspace: Option<&str>, +) -> Result { + clawpal_core::doctor::resolve_agent_workspace_from_config(cfg, agent_id, default_workspace) +} + +fn resolve_agent_entry<'a>(cfg: &'a Value, agent_id: &str) -> Result<&'a Value, String> { + let agents_list = cfg + .get("agents") + .and_then(|agents| agents.get("list")) + .and_then(Value::as_array) + .ok_or_else(|| "agents.list not found".to_string())?; + + agents_list + .iter() + .find(|agent| agent.get("id").and_then(Value::as_str) == Some(agent_id)) + .ok_or_else(|| format!("Agent '{}' not found", agent_id)) +} + +fn resolve_identity_explicit_defaults( + cfg: &Value, + agent_id: &str, +) -> Result { + let agent = resolve_agent_entry(cfg, agent_id)?; + let name = agent + .get("identity") + .and_then(|value| value.get("name")) + .or_else(|| agent.get("identityName")) + .or_else(|| agent.get("name")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let emoji = agent + .get("identity") + .and_then(|value| value.get("emoji")) + .or_else(|| agent.get("identityEmoji")) + .or_else(|| agent.get("emoji")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + Ok(IdentityDocument { + name, + emoji, + persona: None, + }) +} + +fn resolve_identity_defaults(cfg: &Value, agent_id: &str) -> Result { + let mut defaults = resolve_identity_explicit_defaults(cfg, agent_id)?; + if defaults.name.is_none() { + defaults.name = Some(agent_id.to_string()); + } + Ok(defaults) +} + +fn push_unique_candidate(candidates: &mut Vec, candidate: Option) { + let Some(candidate) = candidate.map(|value| value.trim().to_string()) else { + return; + }; + if candidate.is_empty() || candidates.iter().any(|existing| existing == &candidate) { + return; + } + candidates.push(candidate); +} + +fn resolve_identity_dir_candidates( + cfg: &Value, + agent_id: &str, + fallback_agent_root: Option<&str>, +) -> Result, String> { + let agent = resolve_agent_entry(cfg, agent_id)?; + let mut candidates = Vec::new(); + + push_unique_candidate( + &mut candidates, + agent + .get("agentDir") + .and_then(Value::as_str) + .map(str::to_string), + ); + push_unique_candidate( + &mut candidates, + fallback_agent_root + .map(|root| format!("{}/{}/agent", root.trim_end_matches('/'), agent_id)), + ); + push_unique_candidate( + &mut candidates, + agent + .get("workspace") + .and_then(Value::as_str) + .map(str::to_string), + ); + push_unique_candidate(&mut candidates, resolve_workspace(cfg, agent_id, None).ok()); + + if candidates.is_empty() { + return Err(format!( + "Agent '{}' has no workspace or identity directory configured", + agent_id + )); + } + + Ok(candidates) +} + +fn resolve_local_identity_path( + cfg: &Value, + paths: &OpenClawPaths, + agent_id: &str, +) -> Result { + let fallback_root = paths + .openclaw_dir + .join("agents") + .to_string_lossy() + .to_string(); + let candidate_dirs = resolve_identity_dir_candidates(cfg, agent_id, Some(&fallback_root))?; + let candidate_paths: Vec = candidate_dirs + .into_iter() + .map(|path| PathBuf::from(shellexpand::tilde(&path).to_string())) + .collect(); + + if let Some(existing) = candidate_paths + .iter() + .map(|dir| dir.join("IDENTITY.md")) + .find(|path| path.exists()) + { + return Ok(existing); + } + + let agent = resolve_agent_entry(cfg, agent_id)?; + let create_dir = agent + .get("workspace") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| resolve_workspace(cfg, agent_id, None).ok()) + .or_else(|| { + agent + .get("agentDir") + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| Some(format!("{}/{}/agent", fallback_root, agent_id))); + + create_dir + .map(|dir| PathBuf::from(shellexpand::tilde(&dir).to_string()).join("IDENTITY.md")) + .ok_or_else(|| format!("Agent '{}' has no identity path candidates", agent_id)) +} + +fn normalize_remote_dir(path: &str) -> String { + if path.starts_with("~/") || path.starts_with('/') { + path.to_string() + } else { + format!("~/{path}") + } +} + +async fn resolve_remote_identity_path( + pool: &SshConnectionPool, + host_id: &str, + cfg: &Value, + agent_id: &str, +) -> Result { + let fallback_root = "~/.openclaw/agents"; + let candidate_dirs = resolve_identity_dir_candidates(cfg, agent_id, Some(fallback_root))?; + let candidate_dirs: Vec = candidate_dirs + .into_iter() + .map(|dir| normalize_remote_dir(&dir)) + .collect(); + + for dir in &candidate_dirs { + let identity_path = format!("{dir}/IDENTITY.md"); + match pool.sftp_read(host_id, &identity_path).await { + Ok(_) => return Ok(identity_path), + Err(error) if error.contains("No such file") || error.contains("not found") => continue, + Err(error) => return Err(error), + } + } + + let agent = resolve_agent_entry(cfg, agent_id)?; + let create_dir = agent + .get("workspace") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| resolve_workspace(cfg, agent_id, None).ok()) + .or_else(|| { + agent + .get("agentDir") + .and_then(Value::as_str) + .map(str::to_string) + }) + .or_else(|| Some(format!("{fallback_root}/{agent_id}/agent"))); + + create_dir + .map(|dir| format!("{}/IDENTITY.md", normalize_remote_dir(&dir))) + .ok_or_else(|| format!("Agent '{}' has no identity path candidates", agent_id)) +} + +pub fn write_local_agent_identity( + paths: &OpenClawPaths, + agent_id: &str, + name: Option<&str>, + emoji: Option<&str>, + persona: Option<&str>, +) -> Result<(), String> { + let cfg = read_openclaw_config(paths)?; + let identity_path = resolve_local_identity_path(&cfg, paths, agent_id)?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let identity_dir = identity_path + .parent() + .ok_or_else(|| "Failed to resolve identity directory".to_string())?; + fs::create_dir_all(identity_dir) + .map_err(|error| format!("Failed to create workspace dir: {}", error))?; + let existing = fs::read_to_string(&identity_path).ok(); + fs::write( + &identity_path, + identity_content( + existing.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + name, + emoji, + persona + .map(PersonaChange::Set) + .unwrap_or(PersonaChange::Preserve), + )?, + ) + .map_err(|error| format!("Failed to write IDENTITY.md: {}", error))?; + Ok(()) +} + +fn shell_escape(value: &str) -> String { + let escaped = value.replace('\'', "'\\''"); + format!("'{}'", escaped) +} + +pub async fn write_remote_agent_identity( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, + name: Option<&str>, + emoji: Option<&str>, + persona: Option<&str>, +) -> Result<(), String> { + let (_config_path, _raw, cfg) = + crate::commands::remote_read_openclaw_config_text_and_json(pool, host_id) + .await + .map_err(|error| format!("Failed to parse config: {error}"))?; + + let identity_path = resolve_remote_identity_path(pool, host_id, &cfg, agent_id).await?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let remote_workspace = identity_path + .strip_suffix("/IDENTITY.md") + .ok_or_else(|| "Failed to resolve remote identity directory".to_string())?; + pool.exec( + host_id, + &format!("mkdir -p {}", shell_escape(&remote_workspace)), + ) + .await?; + let existing = match pool.sftp_read(host_id, &identity_path).await { + Ok(text) => Some(text), + Err(error) if error.contains("No such file") || error.contains("not found") => None, + Err(error) => return Err(error), + }; + pool.sftp_write( + host_id, + &identity_path, + &identity_content( + existing.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + name, + emoji, + persona + .map(PersonaChange::Set) + .unwrap_or(PersonaChange::Preserve), + )?, + ) + .await?; + Ok(()) +} + +pub fn set_local_agent_persona( + paths: &OpenClawPaths, + agent_id: &str, + persona: &str, +) -> Result<(), String> { + let cfg = read_openclaw_config(paths)?; + let identity_path = resolve_local_identity_path(&cfg, paths, agent_id)?; + let explicit_defaults = resolve_identity_explicit_defaults(&cfg, agent_id)?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let identity_dir = identity_path + .parent() + .ok_or_else(|| "Failed to resolve identity directory".to_string())?; + fs::create_dir_all(identity_dir).map_err(|error| error.to_string())?; + let existing = fs::read_to_string(&identity_path).ok(); + fs::write( + &identity_path, + upsert_persona_content( + existing.as_deref(), + explicit_defaults.name.as_deref(), + explicit_defaults.emoji.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + PersonaChange::Set(persona), + )?, + ) + .map_err(|error| format!("Failed to write IDENTITY.md: {}", error))?; + Ok(()) +} + +pub fn clear_local_agent_persona(paths: &OpenClawPaths, agent_id: &str) -> Result<(), String> { + let cfg = read_openclaw_config(paths)?; + let identity_path = resolve_local_identity_path(&cfg, paths, agent_id)?; + let explicit_defaults = resolve_identity_explicit_defaults(&cfg, agent_id)?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let identity_dir = identity_path + .parent() + .ok_or_else(|| "Failed to resolve identity directory".to_string())?; + fs::create_dir_all(identity_dir).map_err(|error| error.to_string())?; + let existing = fs::read_to_string(&identity_path).ok(); + fs::write( + &identity_path, + upsert_persona_content( + existing.as_deref(), + explicit_defaults.name.as_deref(), + explicit_defaults.emoji.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + PersonaChange::Clear, + )?, + ) + .map_err(|error| format!("Failed to write IDENTITY.md: {}", error))?; + Ok(()) +} + +pub async fn set_remote_agent_persona( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, + persona: &str, +) -> Result<(), String> { + let (_config_path, _raw, cfg) = + crate::commands::remote_read_openclaw_config_text_and_json(pool, host_id) + .await + .map_err(|error| format!("Failed to parse config: {error}"))?; + let identity_path = resolve_remote_identity_path(pool, host_id, &cfg, agent_id).await?; + let explicit_defaults = resolve_identity_explicit_defaults(&cfg, agent_id)?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let remote_workspace = identity_path + .strip_suffix("/IDENTITY.md") + .ok_or_else(|| "Failed to resolve remote identity directory".to_string())?; + pool.exec( + host_id, + &format!("mkdir -p {}", shell_escape(remote_workspace)), + ) + .await?; + let existing = match pool.sftp_read(host_id, &identity_path).await { + Ok(text) => Some(text), + Err(error) if error.contains("No such file") || error.contains("not found") => None, + Err(error) => return Err(error), + }; + pool.sftp_write( + host_id, + &identity_path, + &upsert_persona_content( + existing.as_deref(), + explicit_defaults.name.as_deref(), + explicit_defaults.emoji.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + PersonaChange::Set(persona), + )?, + ) + .await?; + Ok(()) +} + +pub async fn clear_remote_agent_persona( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, +) -> Result<(), String> { + let (_config_path, _raw, cfg) = + crate::commands::remote_read_openclaw_config_text_and_json(pool, host_id) + .await + .map_err(|error| format!("Failed to parse config: {error}"))?; + let identity_path = resolve_remote_identity_path(pool, host_id, &cfg, agent_id).await?; + let explicit_defaults = resolve_identity_explicit_defaults(&cfg, agent_id)?; + let defaults = resolve_identity_defaults(&cfg, agent_id)?; + let remote_workspace = identity_path + .strip_suffix("/IDENTITY.md") + .ok_or_else(|| "Failed to resolve remote identity directory".to_string())?; + pool.exec( + host_id, + &format!("mkdir -p {}", shell_escape(remote_workspace)), + ) + .await?; + let existing = match pool.sftp_read(host_id, &identity_path).await { + Ok(text) => Some(text), + Err(error) if error.contains("No such file") || error.contains("not found") => None, + Err(error) => return Err(error), + }; + pool.sftp_write( + host_id, + &identity_path, + &upsert_persona_content( + existing.as_deref(), + explicit_defaults.name.as_deref(), + explicit_defaults.emoji.as_deref(), + defaults.name.as_deref(), + defaults.emoji.as_deref(), + PersonaChange::Clear, + )?, + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{set_local_agent_persona, write_local_agent_identity}; + use crate::cli_runner::{ + lock_active_override_test_state, set_active_clawpal_data_override, + set_active_openclaw_home_override, + }; + use crate::models::resolve_paths; + use serde_json::json; + use std::fs; + use uuid::Uuid; + + #[test] + fn write_local_agent_identity_creates_identity_file_from_config_workspace() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("lobster"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "lobster", + "workspace": workspace.to_string_lossy(), + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = write_local_agent_identity( + &resolve_paths(), + "lobster", + Some("Lobster"), + Some("🦞"), + Some("You help triage crabby incidents."), + ); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "- Name: Lobster\n- Emoji: 🦞\n\n## Persona\nYou help triage crabby incidents.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } + + #[test] + fn write_local_agent_identity_preserves_name_and_emoji_when_updating_persona_only() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("lobster"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::create_dir_all(&workspace).expect("create workspace dir"); + fs::write( + workspace.join("IDENTITY.md"), + "- Name: Lobster\n- Emoji: 🦞\n\n## Persona\nOld persona.\n", + ) + .expect("write identity seed"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "lobster", + "workspace": workspace.to_string_lossy(), + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = write_local_agent_identity( + &resolve_paths(), + "lobster", + None, + None, + Some("New persona."), + ); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "- Name: Lobster\n- Emoji: 🦞\n\n## Persona\nNew persona.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } + + #[test] + fn write_local_agent_identity_updates_existing_agent_dir_identity_when_workspace_missing() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let agent_dir = openclaw_dir.join("agents").join("main").join("agent"); + fs::create_dir_all(&agent_dir).expect("create agent dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::write( + agent_dir.join("IDENTITY.md"), + "- Name: Main Agent\n- Emoji: 🤖\n\n## Persona\nOld persona.\n", + ) + .expect("write identity seed"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "main", + "model": "anthropic/claude-sonnet-4-20250514", + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = + write_local_agent_identity(&resolve_paths(), "main", None, None, Some("New persona.")); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(agent_dir.join("IDENTITY.md")).expect("read identity file"), + "- Name: Main Agent\n- Emoji: 🤖\n\n## Persona\nNew persona.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } + + #[test] + fn write_local_agent_identity_uses_agent_id_when_identity_file_is_missing() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("test-agent"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "test-agent", + "workspace": workspace.to_string_lossy(), + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = write_local_agent_identity( + &resolve_paths(), + "test-agent", + None, + None, + Some("New persona."), + ); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "- Name: test-agent\n\n## Persona\nNew persona.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } + + #[test] + fn set_local_agent_persona_rewrites_openclaw_identity_template_with_explicit_defaults() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("ops-bot"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::create_dir_all(&workspace).expect("create workspace dir"); + fs::write( + workspace.join("IDENTITY.md"), + "# IDENTITY.md - Who Am I?\n\n_Fill this in during your first conversation._\n", + ) + .expect("write identity seed"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "ops-bot", + "workspace": workspace.to_string_lossy(), + "identity": { + "name": "Ops Bot", + "emoji": "🛰️" + } + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = set_local_agent_persona(&resolve_paths(), "ops-bot", "Keep systems green."); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "- Name: Ops Bot\n- Emoji: 🛰️\n\n## Persona\nKeep systems green.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } + + #[test] + fn set_local_agent_persona_preserves_non_clawpal_identity_header() { + let _override_guard = lock_active_override_test_state(); + let temp_root = std::env::temp_dir().join(format!("clawpal-identity-{}", Uuid::new_v4())); + let openclaw_home = temp_root.join("home"); + let clawpal_data = temp_root.join("data"); + let openclaw_dir = openclaw_home.join(".openclaw"); + let workspace = temp_root.join("workspace").join("ops-bot"); + fs::create_dir_all(&openclaw_dir).expect("create openclaw dir"); + fs::create_dir_all(&clawpal_data).expect("create clawpal data dir"); + fs::create_dir_all(&workspace).expect("create workspace dir"); + fs::write( + workspace.join("IDENTITY.md"), + "# Ops Bot\n\nOpenClaw managed identity header.\n", + ) + .expect("write identity seed"); + fs::write( + openclaw_dir.join("openclaw.json"), + serde_json::to_string_pretty(&json!({ + "agents": { + "list": [ + { + "id": "ops-bot", + "workspace": workspace.to_string_lossy(), + } + ] + } + })) + .expect("serialize config"), + ) + .expect("write config"); + + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set openclaw override"); + set_active_clawpal_data_override(Some(clawpal_data.to_string_lossy().to_string())) + .expect("set clawpal override"); + + let result = set_local_agent_persona(&resolve_paths(), "ops-bot", "Keep systems green."); + + set_active_openclaw_home_override(None).expect("clear openclaw override"); + set_active_clawpal_data_override(None).expect("clear clawpal override"); + + assert!(result.is_ok()); + assert_eq!( + fs::read_to_string(workspace.join("IDENTITY.md")).expect("read identity file"), + "# Ops Bot\n\nOpenClaw managed identity header.\n\n## Persona\nKeep systems green.\n" + ); + + let _ = fs::remove_dir_all(temp_root); + } +} diff --git a/src-tauri/src/cli_runner.rs b/src-tauri/src/cli_runner.rs index ef393cd8..83b499cb 100644 --- a/src-tauri/src/cli_runner.rs +++ b/src-tauri/src/cli_runner.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Instant; @@ -8,12 +9,15 @@ use serde_json::Value; use uuid::Uuid; use crate::models::resolve_paths; +use crate::recipe_executor::MaterializedExecutionPlan; use crate::ssh::SshConnectionPool; static ACTIVE_OPENCLAW_HOME_OVERRIDE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); static ACTIVE_CLAWPAL_DATA_OVERRIDE: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +#[cfg(test)] +static ACTIVE_OVERRIDE_TEST_MUTEX: LazyLock> = LazyLock::new(|| Mutex::new(())); pub fn set_active_openclaw_home_override(path: Option) -> Result<(), String> { let mut guard = ACTIVE_OPENCLAW_HOME_OVERRIDE @@ -55,6 +59,13 @@ pub fn get_active_clawpal_data_override() -> Option { .and_then(|g| g.clone()) } +#[cfg(test)] +pub fn lock_active_override_test_state() -> std::sync::MutexGuard<'static, ()> { + ACTIVE_OVERRIDE_TEST_MUTEX + .lock() + .expect("active override test mutex poisoned") +} + pub type CliOutput = clawpal_core::openclaw::CliOutput; pub fn run_openclaw(args: &[&str]) -> Result { @@ -171,6 +182,131 @@ fn build_remote_openclaw_command(args: &[&str], env: Option<&HashMap String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn allowlisted_systemd_host_command_kind(command: &[String]) -> Option<&'static str> { + match command { + [bin, ..] if bin == "systemd-run" => Some("systemd-run"), + [bin, user, action, ..] + if bin == "systemctl" + && user == "--user" + && matches!(action.as_str(), "stop" | "reset-failed" | "daemon-reload") => + { + Some("systemctl") + } + _ => None, + } +} + +fn is_allowlisted_systemd_host_command(command: &[String]) -> bool { + allowlisted_systemd_host_command_kind(command).is_some() +} + +fn build_remote_shell_command( + command: &[String], + env: Option<&HashMap>, +) -> Result { + if command.is_empty() { + return Err("host command is empty".to_string()); + } + + let mut shell = String::new(); + if let Some(env_vars) = env { + for (key, value) in env_vars { + shell.push_str(&format!("export {}={}; ", key, shell_quote(value))); + } + } + shell.push_str( + &command + .iter() + .map(|part| shell_quote(part)) + .collect::>() + .join(" "), + ); + Ok(shell) +} + +fn run_local_host_command( + command: &[String], + env: Option<&HashMap>, +) -> Result { + let (program, args) = command + .split_first() + .ok_or_else(|| "host command is empty".to_string())?; + let mut process = std::process::Command::new(program); + process.args(args); + if let Some(env_vars) = env { + process.envs(env_vars); + } + let output = process.output().map_err(|error| { + format!( + "failed to start host command '{}': {}", + command.join(" "), + error + ) + })?; + Ok(CliOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(1), + }) +} + +fn run_allowlisted_systemd_local_command(command: &[String]) -> Result, String> { + if !is_allowlisted_systemd_host_command(command) { + return Ok(None); + } + run_local_host_command(command, None).map(Some) +} + +async fn run_allowlisted_systemd_remote_command( + pool: &SshConnectionPool, + host_id: &str, + command: &[String], +) -> Result, String> { + if !is_allowlisted_systemd_host_command(command) { + return Ok(None); + } + let shell = build_remote_shell_command(command, None)?; + let output = pool.exec_login(host_id, &shell).await?; + Ok(Some(CliOutput { + stdout: output.stdout, + stderr: output.stderr, + exit_code: output.exit_code as i32, + })) +} + +fn systemd_dropin_relative_path(target: &str, name: &str) -> String { + format!("~/.config/systemd/user/{}.d/{}", target, name) +} + +fn write_local_systemd_dropin(target: &str, name: &str, content: &str) -> Result<(), String> { + let path = + PathBuf::from(shellexpand::tilde(&systemd_dropin_relative_path(target, name)).to_string()); + crate::config_io::write_text(path.as_path(), content) +} + +async fn write_remote_systemd_dropin( + pool: &SshConnectionPool, + host_id: &str, + target: &str, + name: &str, + content: &str, +) -> Result<(), String> { + let dir = format!("~/.config/systemd/user/{}.d", target); + let resolved_dir = pool.resolve_path(host_id, &dir).await?; + pool.exec(host_id, &format!("mkdir -p {}", shell_quote(&resolved_dir))) + .await?; + pool.sftp_write( + host_id, + &systemd_dropin_relative_path(target, name), + content, + ) + .await +} + pub fn parse_json_output(output: &CliOutput) -> Result { clawpal_core::openclaw::parse_json_output(output).map_err(|e| e.to_string()) } @@ -200,6 +336,51 @@ mod tests { assert!(cmd.contains(" 'a'\\''b'")); } + #[test] + fn allowlisted_systemd_host_commands_are_restricted_to_expected_shapes() { + assert!(is_allowlisted_systemd_host_command(&[ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ])); + assert!(is_allowlisted_systemd_host_command(&[ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ])); + assert!(!is_allowlisted_systemd_host_command(&[ + "systemctl".into(), + "--system".into(), + "daemon-reload".into(), + ])); + assert!(!is_allowlisted_systemd_host_command(&[ + "bash".into(), + "-lc".into(), + "echo nope".into(), + ])); + } + + #[test] + fn rollback_command_supports_snapshot_id_prefix() { + let command = vec![ + "__rollback__".to_string(), + "snapshot_01".to_string(), + "{\"ok\":true}".to_string(), + ]; + + assert_eq!( + rollback_command_snapshot_id(&command).as_deref(), + Some("snapshot_01") + ); + assert_eq!( + rollback_command_content(&command).expect("rollback content"), + "{\"ok\":true}" + ); + } + #[test] fn preview_direct_apply_handles_config_set_and_unset_with_arrays() { let mut config = json!({ @@ -357,6 +538,54 @@ mod tests { assert!(result.is_none()); } + #[test] + fn preview_direct_apply_skips_allowlisted_systemd_commands() { + let mut config = json!({"gateway": {"port": 18789}}); + let host_cmd = PendingCommand { + id: "1".into(), + label: "Run hourly job".into(), + command: vec![ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ], + created_at: String::new(), + }; + + let touched = apply_direct_preview_command(&mut config, &host_cmd) + .expect("preview should accept allowlisted host command") + .expect("host command should be handled directly"); + + assert_eq!(config["gateway"]["port"], json!(18789)); + assert!(!touched.agents && !touched.channels && !touched.bindings && !touched.generic); + } + + #[test] + fn preview_direct_apply_skips_internal_systemd_dropin_write_command() { + let mut config = json!({"gateway": {"port": 18789}}); + let host_cmd = PendingCommand { + id: "1".into(), + label: "Write drop-in".into(), + command: vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + "openclaw-gateway.service".into(), + "10-env.conf".into(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord".into(), + ], + created_at: String::new(), + }; + + let touched = apply_direct_preview_command(&mut config, &host_cmd) + .expect("preview should accept internal drop-in write") + .expect("drop-in write should be handled directly"); + + assert_eq!(config["gateway"]["port"], json!(18789)); + assert!(!touched.agents && !touched.channels && !touched.bindings && !touched.generic); + } + #[test] fn preview_side_effect_warning_marks_agent_commands() { let add_cmd = PendingCommand { @@ -389,6 +618,41 @@ mod tests { .expect("delete warning") .contains("filesystem cleanup")); } + + #[test] + fn preview_side_effect_warning_marks_systemd_commands() { + let host_cmd = PendingCommand { + id: "1".into(), + label: "Run hourly job".into(), + command: vec![ + "systemd-run".into(), + "--unit=clawpal-job-hourly".into(), + "--".into(), + "openclaw".into(), + "doctor".into(), + "run".into(), + ], + created_at: String::new(), + }; + let drop_in_cmd = PendingCommand { + id: "2".into(), + label: "Write drop-in".into(), + command: vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + "openclaw-gateway.service".into(), + "10-env.conf".into(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord".into(), + ], + created_at: String::new(), + }; + + assert!(preview_side_effect_warning(&host_cmd) + .expect("systemd warning") + .contains("host-side systemd changes")); + assert!(preview_side_effect_warning(&drop_in_cmd) + .expect("drop-in warning") + .contains("does not write systemd drop-in")); + } } // --------------------------------------------------------------------------- @@ -457,6 +721,26 @@ impl Default for CommandQueue { } } +pub fn enqueue_materialized_plan( + queue: &CommandQueue, + plan: &MaterializedExecutionPlan, +) -> Vec { + plan.commands + .iter() + .enumerate() + .map(|(index, command)| { + let label = format!( + "[{}] {} ({}/{})", + plan.execution_kind, + plan.unit_name, + index + 1, + plan.commands.len() + ); + queue.enqueue(label, command.clone()) + }) + .collect() +} + // --------------------------------------------------------------------------- // Tauri commands — Task 3 // --------------------------------------------------------------------------- @@ -807,6 +1091,9 @@ fn apply_direct_preview_command( }; match first { + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND => { + return Ok(Some(PreviewTouchedDomains::default())); + } "__config_write__" | "__rollback__" => { let Some(content) = cmd.command.get(1) else { return Err(format!("{}: missing config payload", cmd.label)); @@ -817,6 +1104,9 @@ fn apply_direct_preview_command( return Ok(Some(touched)); } "openclaw" => {} + _ if is_allowlisted_systemd_host_command(&cmd.command) => { + return Ok(Some(PreviewTouchedDomains::default())); + } _ => return Ok(None), } @@ -901,23 +1191,44 @@ fn apply_direct_preview_command( } fn preview_side_effect_warning(cmd: &PendingCommand) -> Option { + if cmd.command.first().map(|value| value.as_str()) + == Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) + { + let target = cmd.command.get(1).map(String::as_str).unwrap_or("systemd"); + let name = cmd.command.get(2).map(String::as_str).unwrap_or("drop-in"); + return Some(format!( + "{}: preview does not write systemd drop-in '{}:{}'; file creation will run during apply.", + cmd.label, target, name + )); + } + + if let Some(kind) = allowlisted_systemd_host_command_kind(&cmd.command) { + return Some(format!( + "{}: preview does not execute allowlisted {} command '{}'; host-side systemd changes will run during apply.", + cmd.label, + kind, + cmd.command.join(" ") + )); + } + let [bin, category, action, target, ..] = cmd.command.as_slice() else { return None; }; - if bin != "openclaw" || category != "agents" { - return None; - } - match action.as_str() { - "add" => Some(format!( - "{}: preview only validates config changes; agent workspace/filesystem setup for '{}' will run during apply.", - cmd.label, target - )), - "delete" => Some(format!( - "{}: preview only validates config changes; any filesystem cleanup for '{}' is not simulated.", - cmd.label, target - )), - _ => None, + if bin == "openclaw" && category == "agents" { + return match action.as_str() { + "add" => Some(format!( + "{}: preview only validates config changes; agent workspace/filesystem setup for '{}' will run during apply.", + cmd.label, target + )), + "delete" => Some(format!( + "{}: preview only validates config changes; any filesystem cleanup for '{}' is not simulated.", + cmd.label, target + )), + _ => None, + }; } + + None } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1196,18 +1507,479 @@ pub struct ApplyQueueResult { pub rolled_back: bool, } -#[tauri::command] -pub async fn apply_queued_commands( - queue: tauri::State<'_, CommandQueue>, - cache: tauri::State<'_, CliCache>, +fn rollback_command_snapshot_id(command: &[String]) -> Option { + if command.first().map(|value| value.as_str()) != Some("__rollback__") { + return None; + } + if command.len() >= 3 { + return command + .get(1) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + } + None +} + +fn rollback_command_content(command: &[String]) -> Result { + match command.first().map(|value| value.as_str()) { + Some("__rollback__") if command.len() >= 3 => command + .get(2) + .cloned() + .ok_or_else(|| "internal rollback is missing content".to_string()), + Some("__rollback__") | Some("__config_write__") => command + .get(1) + .cloned() + .ok_or_else(|| "internal config write is missing content".to_string()), + _ => command + .get(1) + .cloned() + .ok_or_else(|| "internal config write is missing content".to_string()), + } +} + +fn apply_internal_local_command( + paths: &crate::models::OpenClawPaths, + command: &[String], +) -> Result { + fn content(command: &[String]) -> Result { + rollback_command_content(command) + } + match command.first().map(|value| value.as_str()) { + Some("__config_write__") | Some("__rollback__") => { + let content = content(command)?; + crate::config_io::write_text(&paths.config_path, &content)?; + Ok(true) + } + Some(crate::commands::INTERNAL_SETUP_IDENTITY_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "setup_identity command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "setup_identity command missing agent id".to_string())?; + crate::agent_identity::write_local_agent_identity( + paths, + agent_id, + payload.get("name").and_then(serde_json::Value::as_str), + payload.get("emoji").and_then(serde_json::Value::as_str), + payload.get("persona").and_then(serde_json::Value::as_str), + )?; + Ok(true) + } + Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) => { + let target = command + .get(1) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing target unit".to_string())?; + let name = command + .get(2) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing name".to_string())?; + let content = command + .get(3) + .map(String::as_str) + .ok_or_else(|| "systemd drop-in command missing content".to_string())?; + write_local_systemd_dropin(target, name, content)?; + Ok(true) + } + Some(crate::commands::INTERNAL_AGENT_PERSONA_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "agent persona command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "agent persona command missing agentId".to_string())?; + if payload.get("clear").and_then(serde_json::Value::as_bool) == Some(true) { + crate::agent_identity::clear_local_agent_persona(paths, agent_id)?; + } else { + let persona = payload + .get("persona") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "agent persona command missing persona".to_string())?; + crate::agent_identity::set_local_agent_persona(paths, agent_id, persona)?; + } + Ok(true) + } + Some(crate::commands::INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "markdown write command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + crate::markdown_document::write_local_markdown_document(paths, &payload)?; + Ok(true) + } + Some(crate::commands::INTERNAL_MARKDOWN_DOCUMENT_DELETE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "markdown delete command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + crate::markdown_document::delete_local_markdown_document(paths, &payload)?; + Ok(true) + } + Some(crate::commands::INTERNAL_SET_AGENT_MODEL_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "set agent model command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "set agent model command missing agentId".to_string())?; + let model_value = payload + .get("modelValue") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + crate::commands::set_local_agent_model_for_recipe(paths, agent_id, model_value)?; + Ok(true) + } + Some(crate::commands::INTERNAL_ENSURE_MODEL_PROFILE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "ensure model profile command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let profile_id = payload + .get("profileId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "ensure model profile command missing profileId".to_string())?; + crate::commands::profiles::ensure_local_model_profiles_internal( + paths, + &[profile_id.to_string()], + )?; + Ok(true) + } + Some(crate::commands::INTERNAL_ENSURE_PROVIDER_AUTH_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "ensure provider auth command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let provider = payload + .get("provider") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "ensure provider auth command missing provider".to_string())?; + let auth_ref = payload.get("authRef").and_then(serde_json::Value::as_str); + crate::commands::ensure_local_provider_auth_for_recipe(paths, provider, auth_ref)?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_MODEL_PROFILE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete model profile command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let profile_id = payload + .get("profileId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete model profile command missing profileId".to_string())?; + let delete_auth_ref = payload + .get("deleteAuthRef") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + crate::commands::delete_local_model_profile_for_recipe( + paths, + profile_id, + delete_auth_ref, + )?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_PROVIDER_AUTH_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete provider auth command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let auth_ref = payload + .get("authRef") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete provider auth command missing authRef".to_string())?; + let force = payload + .get("force") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + crate::commands::delete_local_provider_auth_for_recipe(paths, auth_ref, force)?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_AGENT_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete agent command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete agent command missing agentId".to_string())?; + let force = payload + .get("force") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let rebind_channels_to = payload + .get("rebindChannelsTo") + .and_then(serde_json::Value::as_str); + crate::commands::delete_local_agent_for_recipe( + paths, + agent_id, + force, + rebind_channels_to, + )?; + Ok(true) + } + _ => Ok(false), + } +} + +async fn apply_internal_remote_command( + pool: &SshConnectionPool, + host_id: &str, + command: &[String], +) -> Result { + fn content(command: &[String]) -> Result { + rollback_command_content(command) + } + match command.first().map(|value| value.as_str()) { + Some("__config_write__") | Some("__rollback__") => { + let content = content(command)?; + pool.sftp_write(host_id, "~/.openclaw/openclaw.json", &content) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_SETUP_IDENTITY_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "setup_identity command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "setup_identity command missing agent id".to_string())?; + crate::agent_identity::write_remote_agent_identity( + pool, + host_id, + agent_id, + payload.get("name").and_then(serde_json::Value::as_str), + payload.get("emoji").and_then(serde_json::Value::as_str), + payload.get("persona").and_then(serde_json::Value::as_str), + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND) => { + let target = command + .get(1) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing target unit".to_string())?; + let name = command + .get(2) + .map(String::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "systemd drop-in command missing name".to_string())?; + let content = command + .get(3) + .map(String::as_str) + .ok_or_else(|| "systemd drop-in command missing content".to_string())?; + write_remote_systemd_dropin(pool, host_id, target, name, content).await?; + Ok(true) + } + Some(crate::commands::INTERNAL_AGENT_PERSONA_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "agent persona command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "agent persona command missing agentId".to_string())?; + if payload.get("clear").and_then(serde_json::Value::as_bool) == Some(true) { + crate::agent_identity::clear_remote_agent_persona(pool, host_id, agent_id).await?; + } else { + let persona = payload + .get("persona") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "agent persona command missing persona".to_string())?; + crate::agent_identity::set_remote_agent_persona(pool, host_id, agent_id, persona) + .await?; + } + Ok(true) + } + Some(crate::commands::INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "markdown write command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + crate::markdown_document::write_remote_markdown_document(pool, host_id, &payload) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_MARKDOWN_DOCUMENT_DELETE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "markdown delete command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + crate::markdown_document::delete_remote_markdown_document(pool, host_id, &payload) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_SET_AGENT_MODEL_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "set agent model command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "set agent model command missing agentId".to_string())?; + let model_value = payload + .get("modelValue") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + crate::commands::set_remote_agent_model_for_recipe( + pool, + host_id, + agent_id, + model_value, + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_ENSURE_MODEL_PROFILE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "ensure model profile command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let profile_id = payload + .get("profileId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "ensure model profile command missing profileId".to_string())?; + crate::commands::profiles::ensure_remote_model_profiles_internal( + pool, + host_id, + &[profile_id.to_string()], + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_ENSURE_PROVIDER_AUTH_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "ensure provider auth command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let provider = payload + .get("provider") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "ensure provider auth command missing provider".to_string())?; + let auth_ref = payload.get("authRef").and_then(serde_json::Value::as_str); + crate::commands::ensure_remote_provider_auth_for_recipe( + pool, host_id, provider, auth_ref, + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_MODEL_PROFILE_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete model profile command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let profile_id = payload + .get("profileId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete model profile command missing profileId".to_string())?; + let delete_auth_ref = payload + .get("deleteAuthRef") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + crate::commands::delete_remote_model_profile_for_recipe( + pool, + host_id, + profile_id, + delete_auth_ref, + ) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_PROVIDER_AUTH_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete provider auth command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let auth_ref = payload + .get("authRef") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete provider auth command missing authRef".to_string())?; + let force = payload + .get("force") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + crate::commands::delete_remote_provider_auth_for_recipe(pool, host_id, auth_ref, force) + .await?; + Ok(true) + } + Some(crate::commands::INTERNAL_DELETE_AGENT_COMMAND) => { + let payload = command + .get(1) + .ok_or_else(|| "delete agent command missing payload".to_string())?; + let payload: serde_json::Value = + serde_json::from_str(payload).map_err(|error| error.to_string())?; + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "delete agent command missing agentId".to_string())?; + let force = payload + .get("force") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let rebind_channels_to = payload + .get("rebindChannelsTo") + .and_then(serde_json::Value::as_str); + crate::commands::delete_remote_agent_for_recipe( + pool, + host_id, + agent_id, + force, + rebind_channels_to, + ) + .await?; + Ok(true) + } + _ => Ok(false), + } +} + +pub async fn apply_queued_commands_with_services( + queue: &CommandQueue, + cache: &CliCache, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, ) -> Result { let commands = queue.list(); if commands.is_empty() { return Err("No pending commands to apply".into()); } - let queue_handle = queue.inner().clone(); - let cache_handle = cache.inner().clone(); + let queue_handle = queue.clone(); + let cache_handle = cache.clone(); tauri::async_runtime::spawn_blocking(move || { let paths = resolve_paths(); @@ -1232,47 +2004,57 @@ pub async fn apply_queued_commands( .any(|c| c.command.first().map(|s| s.as_str()) == Some("__rollback__")); let source = if is_rollback { "rollback" } else { "clawpal" }; let can_rollback = !is_rollback; + let snapshot_recipe_id = snapshot_recipe_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(summary); let _ = crate::history::add_snapshot( &paths.history_dir, &paths.metadata_path, - Some(summary), + Some(snapshot_recipe_id), source, can_rollback, &config_before, + run_id.clone(), None, + snapshot_artifacts.clone().unwrap_or_default(), ); // Execute each command for real let mut applied_count = 0; for cmd in &commands { - if matches!( - cmd.command.first().map(|s| s.as_str()), - Some("__config_write__") | Some("__rollback__") - ) { - // Internal command: write config content directly - if let Some(content) = cmd.command.get(1) { - if let Err(e) = crate::config_io::write_text(&paths.config_path, content) { - let _ = crate::config_io::write_text(&paths.config_path, &config_before); - queue_handle.clear(); - return Ok(ApplyQueueResult { - ok: false, - applied_count, - total_count, - error: Some(format!( - "Step {} failed ({}): {}", - applied_count + 1, - cmd.label, - e - )), - rolled_back: true, - }); - } + match apply_internal_local_command(&paths, &cmd.command) { + Ok(true) => { + applied_count += 1; + continue; + } + Ok(false) => {} + Err(e) => { + let _ = crate::config_io::write_text(&paths.config_path, &config_before); + queue_handle.clear(); + return Ok(ApplyQueueResult { + ok: false, + applied_count, + total_count, + error: Some(format!( + "Step {} failed ({}): {}", + applied_count + 1, + cmd.label, + e + )), + rolled_back: true, + }); } - applied_count += 1; - continue; } - let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); - let result = run_openclaw(&args); + let result = match run_allowlisted_systemd_local_command(&cmd.command) { + Ok(Some(output)) => Ok(output), + Ok(None) => { + let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); + run_openclaw(&args) + } + Err(error) => Err(error), + }; match result { Ok(output) if output.exit_code != 0 => { let detail = if !output.stderr.is_empty() { @@ -1342,6 +2124,24 @@ pub async fn apply_queued_commands( .map_err(|e| e.to_string())? } +#[tauri::command] +pub async fn apply_queued_commands( + queue: tauri::State<'_, CommandQueue>, + cache: tauri::State<'_, CliCache>, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, +) -> Result { + apply_queued_commands_with_services( + queue.inner(), + cache.inner(), + snapshot_recipe_id, + run_id, + snapshot_artifacts, + ) + .await +} + // --------------------------------------------------------------------------- // RemoteCommandQueues — Task 6: per-host command queues // --------------------------------------------------------------------------- @@ -1412,6 +2212,27 @@ impl Default for RemoteCommandQueues { } } +pub fn enqueue_materialized_plan_remote( + queues: &RemoteCommandQueues, + host_id: &str, + plan: &MaterializedExecutionPlan, +) -> Vec { + plan.commands + .iter() + .enumerate() + .map(|(index, command)| { + let label = format!( + "[{}] {} ({}/{})", + plan.execution_kind, + plan.unit_name, + index + 1, + plan.commands.len() + ); + queues.enqueue(host_id, label, command.clone()) + }) + .collect() +} + // --------------------------------------------------------------------------- // Remote queue management Tauri commands // --------------------------------------------------------------------------- @@ -1727,11 +2548,13 @@ pub async fn remote_preview_queued_commands( // Remote apply — execute queue for real via SSH, rollback on failure // --------------------------------------------------------------------------- -#[tauri::command] -pub async fn remote_apply_queued_commands( - pool: tauri::State<'_, SshConnectionPool>, - queues: tauri::State<'_, RemoteCommandQueues>, +pub async fn remote_apply_queued_commands_with_services( + pool: &SshConnectionPool, + queues: &RemoteCommandQueues, host_id: String, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, ) -> Result { let commands = queues.list(&host_id); if commands.is_empty() { @@ -1771,44 +2594,70 @@ pub async fn remote_apply_queued_commands( let _ = pool .sftp_write(&host_id, &snapshot_path, &config_before) .await; + let snapshot_recipe_id = snapshot_recipe_id + .clone() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or(summary.clone()); + let snapshot_created_at = chrono::DateTime::from_timestamp(ts, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| ts.to_string()); + let _ = crate::commands::config::record_remote_snapshot_metadata( + &pool, + &host_id, + crate::history::SnapshotMeta { + id: snapshot_filename.clone(), + recipe_id: Some(snapshot_recipe_id), + created_at: snapshot_created_at, + config_path: snapshot_path.clone(), + source: source.into(), + can_rollback: !is_rollback, + run_id: run_id.clone(), + rollback_of: None, + artifacts: snapshot_artifacts.clone().unwrap_or_default(), + }, + ) + .await; // Execute each command let mut applied_count = 0; for cmd in &commands { - // Handle internal commands (__config_write__, __rollback__) — write config directly - if matches!( - cmd.command.first().map(|s| s.as_str()), - Some("__config_write__") | Some("__rollback__") - ) { - if let Some(content) = cmd.command.get(1) { - if let Err(e) = pool - .sftp_write(&host_id, "~/.openclaw/openclaw.json", content) - .await - { - let _ = pool - .sftp_write(&host_id, "~/.openclaw/openclaw.json", &config_before) - .await; - queues.clear(&host_id); - return Ok(ApplyQueueResult { - ok: false, - applied_count, - total_count, - error: Some(format!( - "Step {} failed ({}): {}", - applied_count + 1, - cmd.label, - e - )), - rolled_back: true, - }); - } + match apply_internal_remote_command(&pool, &host_id, &cmd.command).await { + Ok(true) => { + applied_count += 1; + continue; + } + Ok(false) => {} + Err(e) => { + let _ = pool + .sftp_write(&host_id, "~/.openclaw/openclaw.json", &config_before) + .await; + queues.clear(&host_id); + return Ok(ApplyQueueResult { + ok: false, + applied_count, + total_count, + error: Some(format!( + "Step {} failed ({}): {}", + applied_count + 1, + cmd.label, + e + )), + rolled_back: true, + }); } - applied_count += 1; - continue; } - let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); - match run_openclaw_remote(&pool, &host_id, &args).await { + let result = + match run_allowlisted_systemd_remote_command(&pool, &host_id, &cmd.command).await { + Ok(Some(output)) => Ok(output), + Ok(None) => { + let args: Vec<&str> = cmd.command.iter().skip(1).map(|s| s.as_str()).collect(); + run_openclaw_remote(&pool, &host_id, &args).await + } + Err(error) => Err(error), + }; + match result { Ok(output) if output.exit_code != 0 => { let detail = if !output.stderr.is_empty() { output.stderr.clone() @@ -1869,6 +2718,26 @@ pub async fn remote_apply_queued_commands( }) } +#[tauri::command] +pub async fn remote_apply_queued_commands( + pool: tauri::State<'_, SshConnectionPool>, + queues: tauri::State<'_, RemoteCommandQueues>, + host_id: String, + snapshot_recipe_id: Option, + run_id: Option, + snapshot_artifacts: Option>, +) -> Result { + remote_apply_queued_commands_with_services( + pool.inner(), + queues.inner(), + host_id, + snapshot_recipe_id, + run_id, + snapshot_artifacts, + ) + .await +} + // --------------------------------------------------------------------------- // Read Cache — invalidated on Apply // --------------------------------------------------------------------------- diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index be9722b6..0b82c953 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -1,5 +1,23 @@ use super::*; +fn resolve_openclaw_default_workspace(cfg: &Value) -> Option { + cfg.pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + collect_agent_overviews_from_config(cfg) + .into_iter() + .find_map(|agent| agent.workspace.filter(|value| !value.trim().is_empty())) + }) +} + +fn expand_local_workspace_path(workspace: &str) -> String { + shellexpand::tilde(workspace).to_string() +} + #[tauri::command] pub async fn remote_setup_agent_identity( pool: State<'_, SshConnectionPool>, @@ -16,38 +34,15 @@ pub async fn remote_setup_agent_identity( if name.is_empty() { return Err("Name is required".into()); } - - // Read remote config to find agent workspace - let (_config_path, _raw, cfg) = remote_read_openclaw_config_text_and_json(&pool, &host_id) - .await - .map_err(|e| format!("Failed to parse config: {e}"))?; - - let workspace = clawpal_core::doctor::resolve_agent_workspace_from_config( - &cfg, + crate::agent_identity::write_remote_agent_identity( + pool.inner(), + &host_id, &agent_id, - Some("~/.openclaw/agents"), - )?; - - // Build IDENTITY.md content - let mut content = format!("- Name: {}\n", name); - if let Some(ref e) = emoji { - let e = e.trim(); - if !e.is_empty() { - content.push_str(&format!("- Emoji: {}\n", e)); - } - } - - // Write via SSH - let ws = if workspace.starts_with("~/") { - workspace.to_string() - } else { - format!("~/{workspace}") - }; - pool.exec(&host_id, &format!("mkdir -p {}", shell_escape(&ws))) - .await?; - let identity_path = format!("{}/IDENTITY.md", ws); - pool.sftp_write(&host_id, &identity_path, &content).await?; - + Some(&name), + emoji.as_deref(), + None, + ) + .await?; Ok(true) } @@ -107,8 +102,7 @@ pub fn create_agent( } let paths = resolve_paths(); - let mut cfg = read_openclaw_config(&paths)?; - let current = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?; + let cfg = read_openclaw_config(&paths)?; let existing_ids = collect_agent_ids(&cfg); if existing_ids @@ -121,55 +115,33 @@ pub fn create_agent( let model_display = model_value .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()); - - // If independent, create a dedicated workspace directory; - // otherwise inherit the default workspace so the gateway doesn't auto-create one. - let workspace = if independent.unwrap_or(false) { - let ws_dir = paths.base_dir.join("workspaces").join(&agent_id); - fs::create_dir_all(&ws_dir).map_err(|e| e.to_string())?; - let ws_path = ws_dir.to_string_lossy().to_string(); - Some(ws_path) - } else { - cfg.pointer("/agents/defaults/workspace") - .or_else(|| cfg.pointer("/agents/default/workspace")) - .and_then(Value::as_str) - .map(|s| s.to_string()) - }; - - // Build agent entry - let mut agent_obj = serde_json::Map::new(); - agent_obj.insert("id".into(), Value::String(agent_id.clone())); - if let Some(ref model_str) = model_display { - agent_obj.insert("model".into(), Value::String(model_str.clone())); - } - if let Some(ref ws) = workspace { - agent_obj.insert("workspace".into(), Value::String(ws.clone())); - } - - let agents = cfg - .as_object_mut() - .ok_or("config is not an object")? - .entry("agents") - .or_insert_with(|| Value::Object(serde_json::Map::new())) - .as_object_mut() - .ok_or("agents is not an object")?; - let list = agents - .entry("list") - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .ok_or("agents.list is not an array")?; - list.push(Value::Object(agent_obj)); - - write_config_with_snapshot(&paths, ¤t, &cfg, "create-agent")?; - Ok(AgentOverview { - id: agent_id, - name: None, - emoji: None, - model: model_display, - channels: vec![], - online: false, + let _ = independent; + let workspace = resolve_openclaw_default_workspace(&cfg).ok_or_else(|| { + "OpenClaw default workspace could not be resolved for non-interactive agent creation" + .to_string() + })?; + let workspace = expand_local_workspace_path(&workspace); + + let mut args = vec![ + "agents".to_string(), + "add".to_string(), + agent_id.clone(), + "--non-interactive".to_string(), + "--workspace".to_string(), workspace, - }) + ]; + if let Some(model_value) = &model_display { + args.push("--model".to_string()); + args.push(model_value.clone()); + } + let arg_refs: Vec<&str> = args.iter().map(|value| value.as_str()).collect(); + run_openclaw_raw(&arg_refs)?; + + let updated = read_openclaw_config(&paths)?; + collect_agent_overviews_from_config(&updated) + .into_iter() + .find(|agent| agent.id == agent_id) + .ok_or_else(|| "Created agent was not found after OpenClaw refresh".to_string()) } #[tauri::command] @@ -230,27 +202,13 @@ pub fn setup_agent_identity( } let paths = resolve_paths(); - let cfg = read_openclaw_config(&paths)?; - - let workspace = - clawpal_core::doctor::resolve_agent_workspace_from_config(&cfg, &agent_id, None) - .map(|s| expand_tilde(&s))?; - - // Build IDENTITY.md content - let mut content = format!("- Name: {}\n", name); - if let Some(ref e) = emoji { - let e = e.trim(); - if !e.is_empty() { - content.push_str(&format!("- Emoji: {}\n", e)); - } - } - - let ws_path = std::path::Path::new(&workspace); - fs::create_dir_all(ws_path).map_err(|e| format!("Failed to create workspace dir: {}", e))?; - let identity_path = ws_path.join("IDENTITY.md"); - fs::write(&identity_path, &content) - .map_err(|e| format!("Failed to write IDENTITY.md: {}", e))?; - + crate::agent_identity::write_local_agent_identity( + &paths, + &agent_id, + Some(&name), + emoji.as_deref(), + None, + )?; Ok(true) } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 9182d872..65b0853c 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1,5 +1,100 @@ use super::*; +const REMOTE_SNAPSHOT_METADATA_PATH: &str = "~/.clawpal/metadata.json"; + +fn history_page_from_snapshot_index(index: crate::history::SnapshotIndex) -> HistoryPage { + HistoryPage { + items: index + .items + .into_iter() + .map(|item| HistoryItem { + id: item.id, + recipe_id: item.recipe_id, + created_at: item.created_at, + source: item.source, + can_rollback: item.can_rollback, + run_id: item.run_id, + rollback_of: item.rollback_of, + artifacts: item.artifacts, + }) + .collect(), + } +} + +fn fallback_snapshot_meta_from_remote_entry( + entry: &crate::ssh::SftpEntry, +) -> Option { + if entry.name.starts_with('.') || entry.is_dir { + return None; + } + let stem = entry.name.trim_end_matches(".json"); + let parts: Vec<&str> = stem.splitn(3, '-').collect(); + let ts_str = parts.first().copied().unwrap_or("0"); + let source = parts.get(1).copied().unwrap_or("unknown"); + let recipe_id = parts.get(2).map(|s| s.to_string()); + let created_at = ts_str.parse::().unwrap_or(0); + let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0) + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + .unwrap_or_else(|| created_at.to_string()); + Some(crate::history::SnapshotMeta { + id: entry.name.clone(), + recipe_id, + created_at: created_at_iso, + config_path: format!("~/.clawpal/snapshots/{}", entry.name), + source: source.to_string(), + can_rollback: source != "rollback", + run_id: None, + rollback_of: None, + artifacts: Vec::new(), + }) +} + +pub(crate) async fn read_remote_snapshot_index( + pool: &SshConnectionPool, + host_id: &str, +) -> Result { + match pool.sftp_read(host_id, REMOTE_SNAPSHOT_METADATA_PATH).await { + Ok(text) => crate::history::parse_snapshot_index_text(&text), + Err(error) if super::is_remote_missing_path_error(&error) => { + Ok(crate::history::SnapshotIndex::default()) + } + Err(error) => Err(format!( + "Failed to read remote snapshot metadata: {}", + error + )), + } +} + +pub(crate) async fn write_remote_snapshot_index( + pool: &SshConnectionPool, + host_id: &str, + index: &crate::history::SnapshotIndex, +) -> Result<(), String> { + pool.exec(host_id, "mkdir -p ~/.clawpal").await?; + let text = crate::history::render_snapshot_index_text(index)?; + pool.sftp_write(host_id, REMOTE_SNAPSHOT_METADATA_PATH, &text) + .await +} + +pub(crate) async fn record_remote_snapshot_metadata( + pool: &SshConnectionPool, + host_id: &str, + snapshot: crate::history::SnapshotMeta, +) -> Result<(), String> { + let mut index = read_remote_snapshot_index(pool, host_id).await?; + crate::history::upsert_snapshot(&mut index, snapshot); + write_remote_snapshot_index(pool, host_id, &index).await +} + +async fn resolve_remote_snapshot_meta( + pool: &SshConnectionPool, + host_id: &str, + snapshot_id: &str, +) -> Result, String> { + let index = read_remote_snapshot_index(pool, host_id).await?; + Ok(crate::history::find_snapshot(&index, snapshot_id).cloned()) +} + #[tauri::command] pub async fn remote_read_raw_config( pool: State<'_, SshConnectionPool>, @@ -68,42 +163,25 @@ pub async fn remote_apply_config_patch( pub async fn remote_list_history( pool: State<'_, SshConnectionPool>, host_id: String, -) -> Result { +) -> Result { // Ensure dir exists pool.exec(&host_id, "mkdir -p ~/.clawpal/snapshots").await?; let entries = pool.sftp_list(&host_id, "~/.clawpal/snapshots").await?; - let mut items: Vec = Vec::new(); + let mut index = read_remote_snapshot_index(&pool, &host_id).await?; + let known_ids = index + .items + .iter() + .map(|item| item.id.clone()) + .collect::>(); for entry in entries { - if entry.name.starts_with('.') || entry.is_dir { + if known_ids.contains(&entry.name) { continue; } - // Parse filename: {unix_ts}-{source}-{summary}.json - let stem = entry.name.trim_end_matches(".json"); - let parts: Vec<&str> = stem.splitn(3, '-').collect(); - let ts_str = parts.first().unwrap_or(&"0"); - let source = parts.get(1).unwrap_or(&"unknown"); - let recipe_id = parts.get(2).map(|s| s.to_string()); - let created_at = ts_str.parse::().unwrap_or(0); - // Convert Unix timestamp to ISO 8601 format for frontend compatibility - let created_at_iso = chrono::DateTime::from_timestamp(created_at, 0) - .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) - .unwrap_or_else(|| created_at.to_string()); - let is_rollback = *source == "rollback"; - items.push(serde_json::json!({ - "id": entry.name, - "recipeId": recipe_id, - "createdAt": created_at_iso, - "source": source, - "canRollback": !is_rollback, - })); + if let Some(snapshot) = fallback_snapshot_meta_from_remote_entry(&entry) { + crate::history::upsert_snapshot(&mut index, snapshot); + } } - // Sort newest first - items.sort_by(|a, b| { - let ta = a["createdAt"].as_str().unwrap_or(""); - let tb = b["createdAt"].as_str().unwrap_or(""); - tb.cmp(ta) - }); - Ok(serde_json::json!({ "items": items })) + Ok(history_page_from_snapshot_index(index)) } #[tauri::command] @@ -112,7 +190,10 @@ pub async fn remote_preview_rollback( host_id: String, snapshot_id: String, ) -> Result { - let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}"); + let snapshot_path = resolve_remote_snapshot_meta(&pool, &host_id, &snapshot_id) + .await? + .map(|snapshot| snapshot.config_path) + .unwrap_or_else(|| format!("~/.clawpal/snapshots/{snapshot_id}")); let snapshot_text = pool.sftp_read(&host_id, &snapshot_path).await?; let target = clawpal_core::config::validate_config_json(&snapshot_text) .map_err(|e| format!("Failed to parse snapshot: {e}"))?; @@ -143,13 +224,21 @@ pub async fn remote_rollback( host_id: String, snapshot_id: String, ) -> Result { - let snapshot_path = format!("~/.clawpal/snapshots/{snapshot_id}"); + let snapshot_meta = resolve_remote_snapshot_meta(&pool, &host_id, &snapshot_id).await?; + let snapshot_path = snapshot_meta + .as_ref() + .map(|snapshot| snapshot.config_path.clone()) + .unwrap_or_else(|| format!("~/.clawpal/snapshots/{snapshot_id}")); let target_text = pool.sftp_read(&host_id, &snapshot_path).await?; let target = clawpal_core::config::validate_config_json(&target_text) .map_err(|e| format!("Failed to parse snapshot: {e}"))?; let (config_path, current_text, _current) = remote_read_openclaw_config_text_and_json(&pool, &host_id).await?; + let mut warnings = Vec::new(); + if let Some(snapshot) = snapshot_meta.as_ref() { + warnings.extend(super::cleanup_remote_recipe_snapshot(&pool, &host_id, snapshot).await); + } remote_write_config_with_snapshot( &pool, &host_id, @@ -165,7 +254,7 @@ pub async fn remote_rollback( snapshot_id: Some(snapshot_id), config_path, backup_path: None, - warnings: vec!["rolled back".into()], + warnings, errors: Vec::new(), }) } @@ -194,6 +283,8 @@ pub fn apply_config_patch( true, ¤t_text, None, + None, + Vec::new(), )?; let (candidate, _changes) = build_candidate_config_from_template(¤t, &patch_template, ¶ms)?; @@ -216,19 +307,11 @@ pub fn apply_config_patch( pub fn list_history(limit: usize, offset: usize) -> Result { let paths = resolve_paths(); let index = list_snapshots(&paths.metadata_path)?; - let items = index + let items = history_page_from_snapshot_index(index) .items .into_iter() .skip(offset) .take(limit) - .map(|item| HistoryItem { - id: item.id, - recipe_id: item.recipe_id, - created_at: item.created_at, - source: item.source, - can_rollback: item.can_rollback, - rollback_of: item.rollback_of, - }) .collect(); Ok(HistoryPage { items }) } @@ -280,6 +363,7 @@ pub fn rollback(snapshot_id: String) -> Result { let target_text = read_snapshot(&target.config_path)?; let backup = read_openclaw_config(&paths)?; let backup_text = serde_json::to_string_pretty(&backup).map_err(|e| e.to_string())?; + let warnings = super::cleanup_local_recipe_snapshot(&target); let _ = add_snapshot( &paths.history_dir, &paths.metadata_path, @@ -287,7 +371,9 @@ pub fn rollback(snapshot_id: String) -> Result { "rollback", true, &backup_text, + None, Some(target.id.clone()), + Vec::new(), )?; write_text(&paths.config_path, &target_text)?; Ok(ApplyResult { @@ -295,7 +381,49 @@ pub fn rollback(snapshot_id: String) -> Result { snapshot_id: Some(target.id), config_path: paths.config_path.to_string_lossy().to_string(), backup_path: None, - warnings: vec!["rolled back".into()], + warnings, errors: Vec::new(), }) } + +#[cfg(test)] +mod tests { + use super::history_page_from_snapshot_index; + use crate::history::{SnapshotIndex, SnapshotMeta}; + use crate::recipe_store::Artifact; + + #[test] + fn history_page_from_snapshot_index_preserves_run_id_and_artifacts() { + let page = history_page_from_snapshot_index(SnapshotIndex { + items: vec![SnapshotMeta { + id: "1710240000-clawpal-discord-channel-persona.json".into(), + recipe_id: Some("discord-channel-persona".into()), + created_at: "2026-03-12T00:00:00Z".into(), + config_path: "~/.clawpal/snapshots/1710240000-clawpal-discord-channel-persona.json" + .into(), + source: "clawpal".into(), + can_rollback: true, + run_id: Some("run_remote_01".into()), + rollback_of: None, + artifacts: vec![Artifact { + id: "artifact_01".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly.service".into(), + path: None, + }], + }], + }); + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].run_id.as_deref(), Some("run_remote_01")); + assert_eq!( + page.items[0].recipe_id.as_deref(), + Some("discord-channel-persona") + ); + assert_eq!(page.items[0].artifacts.len(), 1); + assert_eq!( + page.items[0].artifacts[0].label, + "clawpal-job-hourly.service" + ); + } +} diff --git a/src-tauri/src/commands/discovery.rs b/src-tauri/src/commands/discovery.rs index 5ba0ebbd..ab90a58d 100644 --- a/src-tauri/src/commands/discovery.rs +++ b/src-tauri/src/commands/discovery.rs @@ -1,5 +1,28 @@ use super::*; +fn discord_sections_from_openclaw_config(cfg: &Value) -> (Value, Value) { + let discord_section = cfg + .pointer("/channels/discord") + .cloned() + .unwrap_or(Value::Null); + let bindings_section = cfg + .get("bindings") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + (discord_section, bindings_section) +} + +fn agent_overviews_from_openclaw_config( + cfg: &Value, + online_set: &std::collections::HashSet, +) -> Vec { + let mut agents = collect_agent_overviews_from_config(cfg); + for agent in &mut agents { + agent.online = online_set.contains(&agent.id); + } + agents +} + #[tauri::command] pub async fn remote_list_discord_guild_channels( pool: State<'_, SshConnectionPool>, @@ -11,22 +34,34 @@ pub async fn remote_list_discord_guild_channels( &["config", "get", "channels.discord", "--json"], ) .await?; - let discord_section = if output.exit_code == 0 { - crate::cli_runner::parse_json_output(&output).unwrap_or(Value::Null) - } else { - Value::Null - }; let bindings_output = crate::cli_runner::run_openclaw_remote( &pool, &host_id, &["config", "get", "bindings", "--json"], ) .await?; + let config_fallback = if output.exit_code == 0 && bindings_output.exit_code == 0 { + None + } else { + remote_read_openclaw_config_text_and_json(&pool, &host_id) + .await + .ok() + .map(|(_, _, cfg)| cfg) + }; + let (fallback_discord_section, fallback_bindings_section) = config_fallback + .as_ref() + .map(discord_sections_from_openclaw_config) + .unwrap_or_else(|| (Value::Null, Value::Array(Vec::new()))); + let discord_section = if output.exit_code == 0 { + crate::cli_runner::parse_json_output(&output).unwrap_or(Value::Null) + } else { + fallback_discord_section + }; let bindings_section = if bindings_output.exit_code == 0 { crate::cli_runner::parse_json_output(&bindings_output) .unwrap_or_else(|_| Value::Array(Vec::new())) } else { - Value::Array(Vec::new()) + fallback_bindings_section }; // Wrap to match existing code expectations (rest of function uses cfg.get("channels").and_then(|c| c.get("discord"))) let cfg = serde_json::json!({ @@ -283,13 +318,12 @@ pub async fn remote_list_discord_guild_channels( Ok(entries) } -#[tauri::command] -pub async fn remote_list_bindings( - pool: State<'_, SshConnectionPool>, +pub async fn remote_list_bindings_with_pool( + pool: &SshConnectionPool, host_id: String, ) -> Result, String> { let output = crate::cli_runner::run_openclaw_remote( - &pool, + pool, &host_id, &["config", "get", "bindings", "--json"], ) @@ -305,6 +339,14 @@ pub async fn remote_list_bindings( clawpal_core::discovery::parse_bindings(&json.to_string()) } +#[tauri::command] +pub async fn remote_list_bindings( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result, String> { + remote_list_bindings_with_pool(pool.inner(), host_id).await +} + #[tauri::command] pub async fn remote_list_channels_minimal( pool: State<'_, SshConnectionPool>, @@ -333,22 +375,12 @@ pub async fn remote_list_channels_minimal( Ok(collect_channel_nodes(&cfg)) } -#[tauri::command] -pub async fn remote_list_agents_overview( - pool: State<'_, SshConnectionPool>, +pub async fn remote_list_agents_overview_with_pool( + pool: &SshConnectionPool, host_id: String, ) -> Result, String> { let output = - run_openclaw_remote_with_autofix(&pool, &host_id, &["agents", "list", "--json"]).await?; - if output.exit_code != 0 { - let details = format!("{}\n{}", output.stderr.trim(), output.stdout.trim()); - return Err(format!( - "openclaw agents list failed ({}): {}", - output.exit_code, - details.trim() - )); - } - let json = crate::cli_runner::parse_json_output(&output)?; + run_openclaw_remote_with_autofix(pool, &host_id, &["agents", "list", "--json"]).await?; // Check which agents have sessions remotely (single command, batch check) // Lists agents whose sessions.json is larger than 2 bytes (not just "{}") let online_set = match pool.exec_login( @@ -363,9 +395,29 @@ pub async fn remote_list_agents_overview( } Err(_) => std::collections::HashSet::new(), // fallback: all offline }; + if output.exit_code != 0 { + if let Ok((_, _, cfg)) = remote_read_openclaw_config_text_and_json(pool, &host_id).await { + return Ok(agent_overviews_from_openclaw_config(&cfg, &online_set)); + } + let details = format!("{}\n{}", output.stderr.trim(), output.stdout.trim()); + return Err(format!( + "openclaw agents list failed ({}): {}", + output.exit_code, + details.trim() + )); + } + let json = crate::cli_runner::parse_json_output(&output)?; parse_agents_cli_output(&json, Some(&online_set)) } +#[tauri::command] +pub async fn remote_list_agents_overview( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result, String> { + remote_list_agents_overview_with_pool(pool.inner(), host_id).await +} + #[tauri::command] pub async fn list_channels() -> Result, String> { tauri::async_runtime::spawn_blocking(|| { @@ -801,15 +853,14 @@ pub async fn refresh_discord_guild_channels() -> Result .map_err(|e| e.to_string())? } -#[tauri::command] -pub async fn list_bindings( - cache: tauri::State<'_, crate::cli_runner::CliCache>, +pub async fn list_bindings_with_cache( + cache: &crate::cli_runner::CliCache, ) -> Result, String> { let cache_key = local_cli_cache_key("bindings"); if let Some(cached) = cache.get(&cache_key, None) { return serde_json::from_str(&cached).map_err(|e| e.to_string()); } - let cache = cache.inner().clone(); + let cache = cache.clone(); let cache_key_cloned = cache_key.clone(); tauri::async_runtime::spawn_blocking(move || { let output = crate::cli_runner::run_openclaw(&["config", "get", "bindings", "--json"])?; @@ -832,14 +883,20 @@ pub async fn list_bindings( } #[tauri::command] -pub async fn list_agents_overview( +pub async fn list_bindings( cache: tauri::State<'_, crate::cli_runner::CliCache>, +) -> Result, String> { + list_bindings_with_cache(cache.inner()).await +} + +pub async fn list_agents_overview_with_cache( + cache: &crate::cli_runner::CliCache, ) -> Result, String> { let cache_key = local_cli_cache_key("agents-list"); if let Some(cached) = cache.get(&cache_key, None) { return serde_json::from_str(&cached).map_err(|e| e.to_string()); } - let cache = cache.inner().clone(); + let cache = cache.clone(); let cache_key_cloned = cache_key.clone(); tauri::async_runtime::spawn_blocking(move || { let output = crate::cli_runner::run_openclaw(&["agents", "list", "--json"])?; @@ -853,3 +910,75 @@ pub async fn list_agents_overview( .await .map_err(|e| e.to_string())? } + +#[tauri::command] +pub async fn list_agents_overview( + cache: tauri::State<'_, crate::cli_runner::CliCache>, +) -> Result, String> { + list_agents_overview_with_cache(cache.inner()).await +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashSet; + + #[test] + fn discord_sections_from_openclaw_config_extracts_discord_and_bindings() { + let cfg = json!({ + "channels": { + "discord": { + "guilds": { + "guild-recipe-lab": { + "name": "Recipe Lab", + "channels": { + "channel-general": { "systemPrompt": "" } + } + } + } + } + }, + "bindings": [ + { "agentId": "main" } + ] + }); + + let (discord, bindings) = discord_sections_from_openclaw_config(&cfg); + + assert_eq!( + discord + .pointer("/guilds/guild-recipe-lab/name") + .and_then(Value::as_str), + Some("Recipe Lab") + ); + assert_eq!(bindings.as_array().map(|items| items.len()), Some(1)); + } + + #[test] + fn agent_overviews_from_openclaw_config_marks_online_agents() { + let cfg = json!({ + "agents": { + "list": [ + { "id": "main", "model": "anthropic/claude-sonnet-4-20250514" }, + { "id": "helper", "identityName": "Helper", "model": "openai/gpt-4o" } + ] + } + }); + let online_set = HashSet::from([String::from("helper")]); + + let agents = agent_overviews_from_openclaw_config(&cfg, &online_set); + + assert_eq!(agents.len(), 2); + assert!( + !agents + .iter() + .find(|agent| agent.id == "main") + .unwrap() + .online + ); + let helper = agents.iter().find(|agent| agent.id == "helper").unwrap(); + assert!(helper.online); + assert_eq!(helper.name.as_deref(), Some("Helper")); + } +} diff --git a/src-tauri/src/commands/doctor_assistant.rs b/src-tauri/src/commands/doctor_assistant.rs index bac699e0..b5156537 100644 --- a/src-tauri/src/commands/doctor_assistant.rs +++ b/src-tauri/src/commands/doctor_assistant.rs @@ -5174,6 +5174,7 @@ mod tests { clawpal_dir: clawpal_dir.clone(), history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 137f8b7d..a9a2e778 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +use chrono::Utc; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; @@ -11,6 +12,7 @@ use std::{ }; use tauri::{AppHandle, Emitter, Manager, State}; +use tauri_plugin_dialog::DialogExt; use crate::access_discovery::probe_engine::{build_probe_plan_for_local, run_probe_with_redaction}; use crate::access_discovery::store::AccessDiscoveryStore; @@ -25,6 +27,13 @@ use crate::openclaw_doc_resolver::{ resolve_local_doc_guidance, resolve_remote_doc_guidance, DocCitation, DocGuidance, DocResolveIssue, DocResolveRequest, RootCauseHypothesis, }; +use crate::recipe_executor::{ + execute_recipe as prepare_recipe_execution, ExecuteRecipeRequest, ExecuteRecipeResult, +}; +use crate::recipe_store::{ + Artifact as RecipeRuntimeArtifact, RecipeStore, ResourceClaim as RecipeRuntimeResourceClaim, + Run as RecipeRuntimeRun, +}; use crate::ssh::{SftpEntry, SshConnectionPool, SshExecResult, SshHostConfig, SshTransferStats}; use clawpal_core::ssh::diagnostic::{ from_any_error, SshDiagnosticReport, SshDiagnosticStatus, SshErrorCode, SshIntent, SshStage, @@ -93,8 +102,22 @@ fn shell_escape(s: &str) -> String { } use crate::recipe::{ - build_candidate_config_from_template, collect_change_paths, format_diff, - load_recipes_with_fallback, ApplyResult, PreviewResult, + build_candidate_config_from_template, collect_change_paths, find_recipe_with_source, + format_diff, load_recipes_from_source_text, load_recipes_with_fallback, validate_recipe_source, + ApplyResult, PreviewResult, RecipeSourceDiagnostics, +}; +use crate::recipe_action_catalog::{ + find_recipe_action as find_recipe_action_catalog_entry, list_recipe_actions as catalog_actions, + RecipeActionCatalogEntry, +}; +use crate::recipe_adapter::export_recipe_source as export_recipe_source_document; +use crate::recipe_library::{ + load_bundled_recipe_descriptors, upgrade_bundled_recipe, RecipeLibraryImportResult, + RecipeSourceImportResult, +}; +use crate::recipe_planner::{build_recipe_plan, build_recipe_plan_from_source_text, RecipePlan}; +use crate::recipe_workspace::{ + approval_required_for, RecipeSourceSaveResult, RecipeWorkspace, RecipeWorkspaceEntry, }; #[derive(Debug, Serialize, Deserialize)] @@ -480,7 +503,11 @@ pub struct HistoryItem { pub source: String, pub can_rollback: bool, #[serde(skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub rollback_of: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -878,15 +905,6 @@ mod parse_agents_cli_output_tests { } } -fn expand_tilde(path: &str) -> String { - if path.starts_with("~/") { - if let Some(home) = std::env::var("HOME").ok() { - return format!("{}{}", home, &path[1..]); - } - } - path.to_string() -} - fn analyze_sessions_sync() -> Result, String> { let paths = resolve_paths(); let agents_root = paths.base_dir.join("agents"); @@ -1221,16 +1239,2335 @@ fn preview_session_sync(agent_id: &str, session_id: &str) -> Result, "content": content, })); } - } + } + + Ok(messages) +} + +#[tauri::command] +pub fn list_recipes(source: Option) -> Result, String> { + let paths = resolve_paths(); + let default_path = paths.clawpal_dir.join("recipes").join("recipes.json"); + Ok(load_recipes_with_fallback(source, &default_path)) +} + +#[tauri::command] +pub fn list_recipes_from_source_text( + source_text: String, +) -> Result, String> { + load_recipes_from_source_text(&source_text) +} + +#[tauri::command] +pub async fn pick_recipe_source_directory(app: AppHandle) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_folder(move |folder_path| { + let result = folder_path + .map(|path| path.into_path().map_err(|error| error.to_string())) + .transpose() + .map(|path| path.map(|value| value.to_string_lossy().to_string())); + let _ = sender.send(result); + }); + + receiver + .await + .map_err(|_| "recipe folder picker was closed before returning a result".to_string())? +} + +#[tauri::command] +pub fn list_recipe_actions() -> Result, String> { + Ok(catalog_actions()) +} + +#[tauri::command] +pub fn validate_recipe_source_text(source_text: String) -> Result { + validate_recipe_source(&source_text) +} + +#[tauri::command] +pub fn list_recipe_workspace_entries( + app_handle: AppHandle, +) -> Result, String> { + let workspace = RecipeWorkspace::from_resolved_paths(); + let bundled = load_bundled_recipe_descriptors(&app_handle)?; + workspace.describe_entries(&bundled) +} + +#[tauri::command] +pub fn read_recipe_workspace_source(slug: String) -> Result { + RecipeWorkspace::from_resolved_paths().read_recipe_source(&slug) +} + +#[tauri::command] +pub fn save_recipe_workspace_source( + slug: String, + source: String, +) -> Result { + RecipeWorkspace::from_resolved_paths().save_recipe_source(&slug, &source) +} + +#[tauri::command] +pub fn import_recipe_library(root_path: String) -> Result { + let root = std::path::PathBuf::from(shellexpand::tilde(root_path.trim()).to_string()); + RecipeWorkspace::from_resolved_paths().import_recipe_library(&root) +} + +#[tauri::command] +pub fn import_recipe_source( + source: String, + overwrite_existing: bool, +) -> Result { + crate::recipe_library::import_recipe_source( + &source, + &RecipeWorkspace::from_resolved_paths(), + overwrite_existing, + ) +} + +#[tauri::command] +pub fn delete_recipe_workspace_source(slug: String) -> Result { + RecipeWorkspace::from_resolved_paths().delete_recipe_source(&slug)?; + Ok(true) +} + +#[tauri::command] +pub fn approve_recipe_workspace_source(slug: String) -> Result { + let workspace = RecipeWorkspace::from_resolved_paths(); + let source = workspace.read_recipe_source(&slug)?; + let digest = RecipeWorkspace::source_digest(&source); + workspace.approve_recipe(&slug, &digest)?; + Ok(true) +} + +#[tauri::command] +pub fn upgrade_bundled_recipe_workspace_source( + app_handle: AppHandle, + slug: String, +) -> Result { + let workspace = RecipeWorkspace::from_resolved_paths(); + upgrade_bundled_recipe(&app_handle, &workspace, &slug) +} + +#[tauri::command] +pub fn export_recipe_source(recipe_id: String, source: Option) -> Result { + let recipe = find_recipe_with_source(&recipe_id, source) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + export_recipe_source_document(&recipe) +} + +#[tauri::command] +pub fn plan_recipe_source( + recipe_id: String, + params: Map, + source_text: String, +) -> Result { + build_recipe_plan_from_source_text(&recipe_id, ¶ms, &source_text) +} + +#[tauri::command] +pub fn plan_recipe( + recipe_id: String, + params: Map, + source: Option, +) -> Result { + let recipe = find_recipe_with_source(&recipe_id, source) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + build_recipe_plan(&recipe, ¶ms) +} + +#[tauri::command] +pub fn list_recipe_instances() -> Result, String> { + RecipeStore::from_resolved_paths().list_instances() +} + +#[tauri::command] +pub fn list_recipe_runs(instance_id: Option) -> Result, String> { + let store = RecipeStore::from_resolved_paths(); + match instance_id { + Some(instance_id) => store.list_runs(&instance_id), + None => store.list_all_runs(), + } +} + +#[tauri::command] +pub fn delete_recipe_runs(instance_id: Option) -> Result { + RecipeStore::from_resolved_paths().delete_runs(instance_id.as_deref()) +} + +fn build_runtime_claims( + spec: &crate::execution_spec::ExecutionSpec, +) -> Vec { + spec.resources + .claims + .iter() + .map(|claim| RecipeRuntimeResourceClaim { + kind: claim.kind.clone(), + id: claim.id.clone(), + target: claim.target.clone(), + path: claim.path.clone(), + }) + .collect() +} + +fn infer_recipe_id(spec: &crate::execution_spec::ExecutionSpec) -> String { + spec.source + .get("recipeId") + .and_then(Value::as_str) + .or_else(|| spec.metadata.name.as_deref()) + .unwrap_or("recipe") + .to_string() +} + +fn persist_recipe_run( + spec: &crate::execution_spec::ExecutionSpec, + prepared: &crate::recipe_executor::ExecuteRecipePrepared, + instance_id: &str, + status: &str, + summary: &str, + started_at: &str, + finished_at: &str, + warnings: &[String], +) -> Result<(), String> { + RecipeStore::from_resolved_paths() + .record_run(RecipeRuntimeRun { + id: prepared.run_id.clone(), + instance_id: instance_id.to_string(), + recipe_id: infer_recipe_id(spec), + execution_kind: prepared.plan.execution_kind.clone(), + runner: prepared.route.runner.clone(), + status: status.to_string(), + summary: summary.to_string(), + started_at: started_at.to_string(), + finished_at: Some(finished_at.to_string()), + artifacts: crate::recipe_executor::build_runtime_artifacts(spec, prepared), + resource_claims: build_runtime_claims(spec), + warnings: warnings.to_vec(), + source_origin: infer_recipe_source_origin(spec), + source_digest: infer_recipe_source_digest(spec), + workspace_path: infer_recipe_workspace_path(spec), + }) + .map(|_| ()) +} + +fn infer_recipe_source_origin(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeSourceOrigin") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn infer_recipe_source_digest(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeSourceDigest") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn infer_recipe_workspace_path(spec: &crate::execution_spec::ExecutionSpec) -> Option { + spec.source + .get("recipeWorkspacePath") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn find_recipe_run(run_id: &str) -> Result, String> { + RecipeStore::from_resolved_paths() + .list_all_runs() + .map(|runs| runs.into_iter().find(|run| run.id == run_id)) +} + +fn execute_local_cleanup_commands(commands: &[Vec]) -> Vec { + let mut warnings = Vec::new(); + for command in commands { + if command.is_empty() { + continue; + } + match Command::new(&command[0]).args(&command[1..]).output() { + Ok(output) if output.status.success() => {} + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + warnings.push(format!( + "Cleanup command failed ({}): {}", + command.join(" "), + detail + )); + } + Err(error) => warnings.push(format!( + "Cleanup command failed to start ({}): {}", + command.join(" "), + error + )), + } + } + warnings +} + +async fn execute_remote_cleanup_commands( + pool: &SshConnectionPool, + host_id: &str, + commands: &[Vec], +) -> Vec { + let mut warnings = Vec::new(); + for command in commands { + if command.is_empty() { + continue; + } + let shell_command = command + .iter() + .map(|part| shell_escape(part)) + .collect::>() + .join(" "); + match pool.exec(host_id, &shell_command).await { + Ok(output) if output.exit_code == 0 => {} + Ok(output) => { + let detail = if !output.stderr.trim().is_empty() { + output.stderr.trim().to_string() + } else { + output.stdout.trim().to_string() + }; + warnings.push(format!( + "Remote cleanup command failed ({}): {}", + command.join(" "), + detail + )); + } + Err(error) => warnings.push(format!( + "Remote cleanup command failed to start ({}): {}", + command.join(" "), + error + )), + } + } + warnings +} + +fn cleanup_local_recipe_artifacts(artifacts: &[RecipeRuntimeArtifact]) -> Vec { + let mut warnings = Vec::new(); + let mut removed_drop_in = false; + + for artifact in artifacts { + if artifact.kind != "systemdDropIn" { + continue; + } + let Some(path) = artifact.path.as_deref() else { + continue; + }; + let expanded = expand_home_path(path); + if !expanded.exists() { + continue; + } + match fs::remove_file(&expanded) { + Ok(()) => { + removed_drop_in = true; + } + Err(error) => warnings.push(format!( + "Failed to remove drop-in artifact {}: {}", + expanded.display(), + error + )), + } + } + + let mut commands = crate::recipe_executor::build_cleanup_commands(artifacts); + if removed_drop_in + && !commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + }) + { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + warnings.extend(execute_local_cleanup_commands(&commands)); + warnings +} + +async fn cleanup_remote_recipe_artifacts( + pool: &SshConnectionPool, + host_id: &str, + artifacts: &[RecipeRuntimeArtifact], +) -> Vec { + let mut warnings = Vec::new(); + let mut removed_drop_in = false; + + for artifact in artifacts { + if artifact.kind != "systemdDropIn" { + continue; + } + let Some(path) = artifact.path.as_deref() else { + continue; + }; + match pool.sftp_remove(host_id, path).await { + Ok(()) => { + removed_drop_in = true; + } + Err(error) if is_remote_missing_path_error(&error) => {} + Err(error) => warnings.push(format!( + "Failed to remove remote drop-in artifact {}: {}", + path, error + )), + } + } + + let mut commands = crate::recipe_executor::build_cleanup_commands(artifacts); + if removed_drop_in + && !commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + }) + { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + warnings.extend(execute_remote_cleanup_commands(pool, host_id, &commands).await); + warnings +} + +fn cleanup_local_recipe_snapshot(snapshot: &crate::history::SnapshotMeta) -> Vec { + if let Some(run_id) = snapshot.run_id.as_deref() { + match find_recipe_run(run_id) { + Ok(Some(run)) => return cleanup_local_recipe_artifacts(&run.artifacts), + Ok(None) if !snapshot.artifacts.is_empty() => {} + Ok(None) => { + return vec![format!( + "No recipe runtime run found for rollback runId {}", + run_id + )]; + } + Err(error) if !snapshot.artifacts.is_empty() => {} + Err(error) => { + return vec![format!( + "Failed to load recipe runtime run {} for rollback: {}", + run_id, error + )]; + } + } + } + cleanup_local_recipe_artifacts(&snapshot.artifacts) +} + +async fn cleanup_remote_recipe_snapshot( + pool: &SshConnectionPool, + host_id: &str, + snapshot: &crate::history::SnapshotMeta, +) -> Vec { + if let Some(run_id) = snapshot.run_id.as_deref() { + match find_recipe_run(run_id) { + Ok(Some(run)) => { + return cleanup_remote_recipe_artifacts(pool, host_id, &run.artifacts).await + } + Ok(None) if !snapshot.artifacts.is_empty() => {} + Ok(None) => { + return vec![format!( + "No recipe runtime run found for rollback runId {}", + run_id + )]; + } + Err(error) if !snapshot.artifacts.is_empty() => {} + Err(error) => { + return vec![format!( + "Failed to load recipe runtime run {} for rollback: {}", + run_id, error + )]; + } + } + } + cleanup_remote_recipe_artifacts(pool, host_id, &snapshot.artifacts).await +} + +pub(crate) const INTERNAL_SETUP_IDENTITY_COMMAND: &str = "__setup_identity__"; +pub(crate) const INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND: &str = "__systemd_dropin_write__"; +pub(crate) const INTERNAL_AGENT_PERSONA_COMMAND: &str = "__agent_persona__"; +pub(crate) const INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND: &str = "__markdown_document_write__"; +pub(crate) const INTERNAL_MARKDOWN_DOCUMENT_DELETE_COMMAND: &str = "__markdown_document_delete__"; +pub(crate) const INTERNAL_SET_AGENT_MODEL_COMMAND: &str = "__set_agent_model__"; +pub(crate) const INTERNAL_ENSURE_MODEL_PROFILE_COMMAND: &str = "__ensure_model_profile__"; +pub(crate) const INTERNAL_ENSURE_PROVIDER_AUTH_COMMAND: &str = "__ensure_provider_auth__"; +pub(crate) const INTERNAL_DELETE_MODEL_PROFILE_COMMAND: &str = "__delete_model_profile__"; +pub(crate) const INTERNAL_DELETE_PROVIDER_AUTH_COMMAND: &str = "__delete_provider_auth__"; +pub(crate) const INTERNAL_DELETE_AGENT_COMMAND: &str = "__delete_agent__"; + +fn recipe_action_internal_command( + label: String, + command_name: &str, + payload: Value, +) -> Result<(String, Vec), String> { + Ok(( + label, + vec![ + command_name.to_string(), + serde_json::to_string(&payload).map_err(|error| error.to_string())?, + ], + )) +} + +fn action_string(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + }) +} + +fn action_content_string(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(text) => { + if text.trim().is_empty() { + None + } else { + Some(text.clone()) + } + } + _ => None, + }) +} + +fn action_bool(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(value)) => *value, + Some(Value::String(value)) => value.trim().eq_ignore_ascii_case("true"), + _ => false, + } +} + +fn action_string_list(value: Option<&Value>) -> Vec { + match value { + Some(Value::String(value)) => value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(), + Some(Value::Array(values)) => values + .iter() + .filter_map(|value| match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} + +fn config_set_value_and_flag( + value: &Value, + strict_json: bool, +) -> Result<(String, Option), String> { + match value { + Value::String(text) if !strict_json => Ok((text.clone(), None)), + _ => Ok(( + serde_json::to_string(value).map_err(|error| error.to_string())?, + Some("--strict-json".into()), + )), + } +} + +fn recipe_action_setup_identity_command( + agent_id: &str, + name: Option<&str>, + emoji: Option<&str>, + persona: Option<&str>, +) -> (String, Vec) { + let mut payload = Map::new(); + payload.insert("agentId".into(), Value::String(agent_id.to_string())); + if let Some(name) = name.map(str::trim).filter(|value| !value.is_empty()) { + payload.insert("name".into(), Value::String(name.to_string())); + } + if let Some(emoji) = emoji.map(str::trim).filter(|value| !value.is_empty()) { + payload.insert("emoji".into(), Value::String(emoji.to_string())); + } + if let Some(persona) = persona.map(str::trim).filter(|value| !value.is_empty()) { + payload.insert("persona".into(), Value::String(persona.to_string())); + } + ( + format!("Setup identity: {}", agent_id), + vec![ + INTERNAL_SETUP_IDENTITY_COMMAND.to_string(), + Value::Object(payload).to_string(), + ], + ) +} + +fn recipe_action_agent_persona_command( + agent_id: &str, + persona: Option<&str>, + clear: bool, +) -> Result<(String, Vec), String> { + let mut payload = Map::new(); + payload.insert("agentId".into(), Value::String(agent_id.to_string())); + if clear { + payload.insert("clear".into(), Value::Bool(true)); + } + if let Some(persona) = persona.map(str::trim).filter(|value| !value.is_empty()) { + payload.insert("persona".into(), Value::String(persona.to_string())); + } + recipe_action_internal_command( + format!("Update persona: {}", agent_id), + INTERNAL_AGENT_PERSONA_COMMAND, + Value::Object(payload), + ) +} + +fn recipe_action_markdown_document_command( + label: &str, + command_name: &str, + args: &Map, +) -> Result<(String, Vec), String> { + recipe_action_internal_command(label.to_string(), command_name, Value::Object(args.clone())) +} + +fn append_config_patch_commands( + value: &Value, + path: &str, + commands: &mut Vec<(String, Vec)>, +) -> Result<(), String> { + match value { + Value::Object(map) => { + for (key, nested) in map { + let next_path = if path.is_empty() { + key.clone() + } else { + format!("{}.{}", path, key) + }; + append_config_patch_commands(nested, &next_path, commands)?; + } + Ok(()) + } + _ => { + let full_path = if path.is_empty() { + ".".to_string() + } else { + path.to_string() + }; + let json_value = serde_json::to_string(value).map_err(|error| error.to_string())?; + commands.push(( + format!("Set {}", full_path), + vec![ + "openclaw".into(), + "config".into(), + "set".into(), + full_path, + json_value, + "--json".into(), + ], + )); + Ok(()) + } + } +} + +fn channel_persona_patch( + channel_type: &str, + guild_id: Option<&str>, + peer_id: &str, + persona: &str, +) -> Result { + match channel_type.trim() { + "discord" => { + let guild_id = guild_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "set_channel_persona requires guildId for discord channels".to_string() + })?; + Ok(json!({ + "channels": { + "discord": { + "guilds": { + guild_id: { + "channels": { + peer_id: { + "systemPrompt": persona, + } + } + } + } + } + } + })) + } + other => Err(format!( + "set_channel_persona does not support channel type '{}'", + other + )), + } +} + +fn rewrite_binding_entries( + bindings: Vec, + channel_type: &str, + peer_id: &str, + agent_id: &str, +) -> Vec { + let mut next: Vec = bindings + .into_iter() + .filter(|binding| { + let Some(matcher) = binding.get("match").and_then(Value::as_object) else { + return true; + }; + let Some(channel) = matcher.get("channel").and_then(Value::as_str) else { + return true; + }; + let Some(peer) = matcher.get("peer").and_then(Value::as_object) else { + return true; + }; + let Some(existing_peer_id) = peer.get("id").and_then(Value::as_str) else { + return true; + }; + !(channel == channel_type && existing_peer_id == peer_id) + }) + .collect(); + + next.push(json!({ + "agentId": agent_id, + "match": { + "channel": channel_type, + "peer": { + "kind": "channel", + "id": peer_id, + } + } + })); + next +} + +fn remove_binding_entries(bindings: Vec, channel_type: &str, peer_id: &str) -> Vec { + bindings + .into_iter() + .filter(|binding| { + let Some(matcher) = binding.get("match").and_then(Value::as_object) else { + return true; + }; + let Some(channel) = matcher.get("channel").and_then(Value::as_str) else { + return true; + }; + let Some(peer) = matcher.get("peer").and_then(Value::as_object) else { + return true; + }; + let Some(existing_peer_id) = peer.get("id").and_then(Value::as_str) else { + return true; + }; + !(channel == channel_type && existing_peer_id == peer_id) + }) + .collect() +} + +fn bindings_reference_agent(bindings: &[Value], agent_id: &str) -> bool { + bindings + .iter() + .any(|binding| binding.get("agentId").and_then(Value::as_str) == Some(agent_id)) +} + +fn rewrite_agent_bindings_for_delete( + bindings: Vec, + agent_id: &str, + rebind_to: Option<&str>, +) -> Vec { + let Some(rebind_to) = rebind_to.map(str::trim).filter(|value| !value.is_empty()) else { + return bindings + .into_iter() + .filter(|binding| binding.get("agentId").and_then(Value::as_str) != Some(agent_id)) + .collect(); + }; + + bindings + .into_iter() + .map(|binding| { + if binding.get("agentId").and_then(Value::as_str) == Some(agent_id) { + let mut next = binding; + if let Some(object) = next.as_object_mut() { + object.insert("agentId".into(), Value::String(rebind_to.to_string())); + } + next + } else { + binding + } + }) + .collect() +} + +async fn resolve_model_value_for_route( + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, + profile_id: Option<&str>, +) -> Result, String> { + let Some(profile_id) = profile_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if profile_id == "__default__" { + return Ok(None); + } + + let profiles = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_model_profiles_with_pool(pool, host_id).await? + } + _ => list_model_profiles()?, + }; + + resolve_model_value_from_profiles(&profiles, profile_id) +} + +fn resolve_model_value_from_profiles( + profiles: &[ModelProfile], + profile_id: &str, +) -> Result, String> { + let trimmed = profile_id.trim(); + if trimmed.is_empty() || trimmed == "__default__" { + return Ok(None); + } + + if let Some(profile) = profiles.iter().find(|profile| profile.id == trimmed) { + return Ok(Some(profile_to_model_value(profile))); + } + + if profiles + .iter() + .map(profile_to_model_value) + .any(|model_value| model_value == trimmed) + { + return Ok(Some(trimmed.to_string())); + } + + Err(format!( + "Model profile is not available on this instance: {trimmed}" + )) +} + +fn resolve_openclaw_default_workspace_from_config(cfg: &Value) -> Option { + cfg.pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + collect_agent_overviews_from_config(cfg) + .into_iter() + .find_map(|agent| agent.workspace.filter(|value| !value.trim().is_empty())) + }) +} + +async fn expand_workspace_for_route( + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, + workspace: &str, +) -> Result { + match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + let home = pool.get_home_dir(&host_id).await?; + if workspace == "~" { + Ok(home) + } else if let Some(relative) = workspace.strip_prefix("~/") { + Ok(format!("{}/{}", home.trim_end_matches('/'), relative)) + } else { + Ok(workspace.to_string()) + } + } + _ => Ok(shellexpand::tilde(workspace).to_string()), + } +} + +async fn resolve_openclaw_default_workspace_for_route( + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result { + match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + let (_, _, cfg) = remote_read_openclaw_config_text_and_json(pool, &host_id).await?; + let workspace = resolve_openclaw_default_workspace_from_config(&cfg).ok_or_else(|| { + "OpenClaw default workspace could not be resolved for non-interactive agent creation" + .to_string() + })?; + expand_workspace_for_route(pool, route, &workspace).await + } + _ => { + let cfg = read_openclaw_config(&resolve_paths())?; + let workspace = resolve_openclaw_default_workspace_from_config(&cfg).ok_or_else(|| { + "OpenClaw default workspace could not be resolved for non-interactive agent creation" + .to_string() + })?; + expand_workspace_for_route(pool, route, &workspace).await + } + } +} + +async fn list_bindings_for_route( + cache: &crate::cli_runner::CliCache, + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result, String> { + match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_bindings_with_pool(pool, host_id).await + } + _ => list_bindings_with_cache(cache).await, + } +} + +async fn materialize_recipe_action_commands( + action: &crate::execution_spec::ExecutionAction, + cache: &crate::cli_runner::CliCache, + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result)>, String> { + let kind = action + .kind + .as_deref() + .ok_or_else(|| "legacy action is missing kind".to_string())?; + let args = action + .args + .as_object() + .ok_or_else(|| format!("legacy action '{}' is missing object args", kind))?; + let catalog_entry = find_recipe_action_catalog_entry(kind) + .ok_or_else(|| format!("recipe action '{}' is not recognized", kind))?; + if !catalog_entry.runner_supported { + return Err(format!( + "recipe action '{}' is documented but not supported by the Recipe runner", + kind + )); + } + + match kind { + "list_agents" => Ok(vec![( + "List agents".into(), + vec![ + "openclaw".into(), + "agents".into(), + "list".into(), + "--json".into(), + ], + )]), + "list_agent_bindings" => Ok(vec![( + "List agent bindings".into(), + vec!["openclaw".into(), "agents".into(), "bindings".into()], + )]), + "create_agent" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "create_agent requires agentId".to_string())?; + let model_profile_id = action_string(args.get("modelProfileId")); + let model_value = + resolve_model_value_for_route(pool, route, model_profile_id.as_deref()).await?; + let workspace = resolve_openclaw_default_workspace_for_route(pool, route).await?; + + let mut command = vec![ + "openclaw".into(), + "agents".into(), + "add".into(), + agent_id.clone(), + "--non-interactive".into(), + "--workspace".into(), + workspace, + ]; + if let Some(model_value) = model_value { + command.push("--model".into()); + command.push(model_value); + } + + Ok(vec![(format!("Create agent: {}", agent_id), command)]) + } + "delete_agent" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "delete_agent requires agentId".to_string())?; + let force = action_bool(args.get("force")); + let rebind_channels_to = action_string(args.get("rebindChannelsTo")); + let bindings = list_bindings_for_route(cache, pool, route).await?; + if !force + && rebind_channels_to.is_none() + && bindings_reference_agent(&bindings, &agent_id) + { + return Err(format!( + "Agent '{}' is still referenced by at least one channel binding", + agent_id + )); + } + recipe_action_internal_command( + format!("Delete agent: {}", agent_id), + INTERNAL_DELETE_AGENT_COMMAND, + json!({ + "agentId": agent_id, + "force": force, + "rebindChannelsTo": rebind_channels_to, + }), + ) + .map(|command| vec![command]) + } + "setup_identity" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "setup_identity requires agentId".to_string())?; + let name = action_string(args.get("name")); + let emoji = action_string(args.get("emoji")); + let persona = action_content_string(args.get("persona")); + if name.is_none() && emoji.is_none() && persona.is_none() { + return Err( + "setup_identity requires at least one of name, emoji, or persona".to_string(), + ); + } + Ok(vec![recipe_action_setup_identity_command( + &agent_id, + name.as_deref(), + emoji.as_deref(), + persona.as_deref(), + )]) + } + "set_agent_identity" => { + let from_identity = action_bool(args.get("fromIdentity")); + let agent_id = action_string(args.get("agentId")); + let workspace = action_string(args.get("workspace")); + let name = action_string(args.get("name")); + let theme = action_string(args.get("theme")); + let emoji = action_string(args.get("emoji")); + let avatar = action_string(args.get("avatar")); + + if from_identity { + if workspace.is_none() { + return Err( + "set_agent_identity with fromIdentity requires workspace".to_string() + ); + } + } else if agent_id.is_none() + || (name.is_none() && theme.is_none() && emoji.is_none() && avatar.is_none()) + { + return Err( + "set_agent_identity requires agentId and at least one of name, theme, emoji, or avatar".to_string(), + ); + } + + let mut command = vec!["openclaw".into(), "agents".into(), "set-identity".into()]; + if let Some(agent_id) = &agent_id { + command.push("--agent".into()); + command.push(agent_id.clone()); + } + if let Some(workspace) = &workspace { + command.push("--workspace".into()); + command.push(workspace.clone()); + } + if from_identity { + command.push("--from-identity".into()); + } + if let Some(name) = &name { + command.push("--name".into()); + command.push(name.clone()); + } + if let Some(theme) = &theme { + command.push("--theme".into()); + command.push(theme.clone()); + } + if let Some(emoji) = &emoji { + command.push("--emoji".into()); + command.push(emoji.clone()); + } + if let Some(avatar) = &avatar { + command.push("--avatar".into()); + command.push(avatar.clone()); + } + + Ok(vec![( + action + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + agent_id + .clone() + .map(|agent_id| format!("Set identity: {}", agent_id)) + .unwrap_or_else(|| "Set identity from workspace".into()) + }), + command, + )]) + } + "set_agent_persona" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "set_agent_persona requires agentId".to_string())?; + let persona = action_content_string(args.get("persona")) + .ok_or_else(|| "set_agent_persona requires persona".to_string())?; + Ok(vec![recipe_action_agent_persona_command( + &agent_id, + Some(&persona), + false, + )?]) + } + "clear_agent_persona" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "clear_agent_persona requires agentId".to_string())?; + Ok(vec![recipe_action_agent_persona_command( + &agent_id, None, true, + )?]) + } + "bind_agent" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "bind_agent requires agentId".to_string())?; + let binding = action_string(args.get("binding")) + .ok_or_else(|| "bind_agent requires binding".to_string())?; + Ok(vec![( + format!("Bind {} -> {}", binding, agent_id), + vec![ + "openclaw".into(), + "agents".into(), + "bind".into(), + "--agent".into(), + agent_id, + "--bind".into(), + binding, + ], + )]) + } + "unbind_agent" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "unbind_agent requires agentId".to_string())?; + let remove_all = action_bool(args.get("all")); + let binding = action_string(args.get("binding")); + if !remove_all && binding.is_none() { + return Err("unbind_agent requires binding or all=true".to_string()); + } + + let mut command = vec![ + "openclaw".into(), + "agents".into(), + "unbind".into(), + "--agent".into(), + agent_id.clone(), + ]; + if remove_all { + command.push("--all".into()); + } else if let Some(binding) = binding { + command.push("--bind".into()); + command.push(binding); + } + + Ok(vec![( + action + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Unbind agent: {}", agent_id)), + command, + )]) + } + "bind_channel" => { + let channel_type = action_string(args.get("channelType")) + .ok_or_else(|| "bind_channel requires channelType".to_string())?; + let peer_id = action_string(args.get("peerId")) + .ok_or_else(|| "bind_channel requires peerId".to_string())?; + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "bind_channel requires agentId".to_string())?; + let bindings = list_bindings_for_route(cache, pool, route).await?; + let payload = rewrite_binding_entries(bindings, &channel_type, &peer_id, &agent_id); + let payload_json = + serde_json::to_string(&payload).map_err(|error| error.to_string())?; + + Ok(vec![( + format!("Bind {}:{} -> {}", channel_type, peer_id, agent_id), + vec![ + "openclaw".into(), + "config".into(), + "set".into(), + "bindings".into(), + payload_json, + "--json".into(), + ], + )]) + } + "unbind_channel" => { + let channel_type = action_string(args.get("channelType")) + .ok_or_else(|| "unbind_channel requires channelType".to_string())?; + let peer_id = action_string(args.get("peerId")) + .ok_or_else(|| "unbind_channel requires peerId".to_string())?; + let bindings = list_bindings_for_route(cache, pool, route).await?; + let payload = remove_binding_entries(bindings, &channel_type, &peer_id); + let payload_json = + serde_json::to_string(&payload).map_err(|error| error.to_string())?; + + Ok(vec![( + format!("Remove binding for {}:{}", channel_type, peer_id), + vec![ + "openclaw".into(), + "config".into(), + "set".into(), + "bindings".into(), + payload_json, + "--json".into(), + ], + )]) + } + "set_agent_model" => { + let agent_id = action_string(args.get("agentId")) + .ok_or_else(|| "set_agent_model requires agentId".to_string())?; + let profile_id = action_string(args.get("profileId")) + .ok_or_else(|| "set_agent_model requires profileId".to_string())?; + let ensure_profile = args + .get("ensureProfile") + .and_then(Value::as_bool) + .unwrap_or(true); + let model_value = resolve_model_value_for_route(pool, route, Some(&profile_id)).await?; + let mut commands = Vec::new(); + if ensure_profile { + commands.push(recipe_action_internal_command( + format!("Prepare model access: {}", profile_id), + INTERNAL_ENSURE_MODEL_PROFILE_COMMAND, + json!({ "profileId": profile_id }), + )?); + } + commands.push(recipe_action_internal_command( + format!("Update model: {}", agent_id), + INTERNAL_SET_AGENT_MODEL_COMMAND, + json!({ + "agentId": agent_id, + "modelValue": model_value, + }), + )?); + Ok(commands) + } + "set_channel_persona" => { + let channel_type = action_string(args.get("channelType")) + .ok_or_else(|| "set_channel_persona requires channelType".to_string())?; + let peer_id = action_string(args.get("peerId")) + .ok_or_else(|| "set_channel_persona requires peerId".to_string())?; + let persona = action_content_string(args.get("persona")) + .ok_or_else(|| "set_channel_persona requires persona".to_string())?; + let guild_id = action_string(args.get("guildId")); + let patch = + channel_persona_patch(&channel_type, guild_id.as_deref(), &peer_id, &persona)?; + let mut commands = Vec::new(); + append_config_patch_commands(&patch, "", &mut commands)?; + Ok(commands) + } + "clear_channel_persona" => { + let channel_type = action_string(args.get("channelType")) + .ok_or_else(|| "clear_channel_persona requires channelType".to_string())?; + let peer_id = action_string(args.get("peerId")) + .ok_or_else(|| "clear_channel_persona requires peerId".to_string())?; + let guild_id = action_string(args.get("guildId")); + let patch = channel_persona_patch(&channel_type, guild_id.as_deref(), &peer_id, "")?; + let mut commands = Vec::new(); + append_config_patch_commands(&patch, "", &mut commands)?; + Ok(commands) + } + "show_config_file" => Ok(vec![( + "Show config file".into(), + vec!["openclaw".into(), "config".into(), "file".into()], + )]), + "get_config_value" => { + let path = action_string(args.get("path")) + .ok_or_else(|| "get_config_value requires path".to_string())?; + Ok(vec![( + format!("Get config value: {}", path), + vec!["openclaw".into(), "config".into(), "get".into(), path], + )]) + } + "set_config_value" => { + let path = action_string(args.get("path")) + .ok_or_else(|| "set_config_value requires path".to_string())?; + let value = args + .get("value") + .ok_or_else(|| "set_config_value requires value".to_string())?; + let (serialized, strict_flag) = + config_set_value_and_flag(value, action_bool(args.get("strictJson")))?; + let mut command = vec![ + "openclaw".into(), + "config".into(), + "set".into(), + path.clone(), + serialized, + ]; + if let Some(flag) = strict_flag { + command.push(flag); + } + Ok(vec![(format!("Set config value: {}", path), command)]) + } + "unset_config_value" => { + let path = action_string(args.get("path")) + .ok_or_else(|| "unset_config_value requires path".to_string())?; + Ok(vec![( + format!("Unset config value: {}", path), + vec!["openclaw".into(), "config".into(), "unset".into(), path], + )]) + } + "validate_config" => { + let mut command = vec!["openclaw".into(), "config".into(), "validate".into()]; + if action_bool(args.get("jsonOutput")) { + command.push("--json".into()); + } + Ok(vec![("Validate config".into(), command)]) + } + "config_patch" => { + let patch = if let Some(patch) = args.get("patch") { + patch.clone() + } else if let Some(template) = action_string(args.get("patchTemplate")) { + json5::from_str::(&template).map_err(|error| error.to_string())? + } else { + return Err("config_patch requires patch or patchTemplate".into()); + }; + + let mut commands = Vec::new(); + append_config_patch_commands(&patch, "", &mut commands)?; + Ok(commands) + } + "upsert_markdown_document" => Ok(vec![recipe_action_markdown_document_command( + action + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Update document"), + INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND, + args, + )?]), + "delete_markdown_document" => Ok(vec![recipe_action_markdown_document_command( + action + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Delete document"), + INTERNAL_MARKDOWN_DOCUMENT_DELETE_COMMAND, + args, + )?]), + "models_status" => { + let mut command = vec!["openclaw".into(), "models".into(), "status".into()]; + if action_bool(args.get("jsonOutput")) { + command.push("--json".into()); + } + if action_bool(args.get("plain")) { + command.push("--plain".into()); + } + if action_bool(args.get("check")) { + command.push("--check".into()); + } + if action_bool(args.get("probe")) { + command.push("--probe".into()); + } + if let Some(provider) = action_string(args.get("probeProvider")) { + command.push("--probe-provider".into()); + command.push(provider); + } + for profile_id in action_string_list(args.get("probeProfile")) { + command.push("--probe-profile".into()); + command.push(profile_id); + } + if let Some(timeout_ms) = action_string(args.get("probeTimeoutMs")) { + command.push("--probe-timeout".into()); + command.push(timeout_ms); + } + if let Some(concurrency) = action_string(args.get("probeConcurrency")) { + command.push("--probe-concurrency".into()); + command.push(concurrency); + } + if let Some(max_tokens) = action_string(args.get("probeMaxTokens")) { + command.push("--probe-max-tokens".into()); + command.push(max_tokens); + } + if let Some(agent_id) = action_string(args.get("agentId")) { + command.push("--agent".into()); + command.push(agent_id); + } + Ok(vec![("Inspect model status".into(), command)]) + } + "list_models" => Ok(vec![( + "List models".into(), + vec!["openclaw".into(), "models".into(), "list".into()], + )]), + "set_default_model" => { + let model_or_alias = action_string(args.get("modelOrAlias")) + .ok_or_else(|| "set_default_model requires modelOrAlias".to_string())?; + Ok(vec![( + format!("Set default model: {}", model_or_alias), + vec![ + "openclaw".into(), + "models".into(), + "set".into(), + model_or_alias, + ], + )]) + } + "scan_models" => Ok(vec![( + "Scan models".into(), + vec!["openclaw".into(), "models".into(), "scan".into()], + )]), + "list_model_aliases" => Ok(vec![( + "List model aliases".into(), + vec![ + "openclaw".into(), + "models".into(), + "aliases".into(), + "list".into(), + ], + )]), + "list_model_fallbacks" => Ok(vec![( + "List model fallbacks".into(), + vec![ + "openclaw".into(), + "models".into(), + "fallbacks".into(), + "list".into(), + ], + )]), + "ensure_model_profile" => { + let profile_id = action_string(args.get("profileId")) + .ok_or_else(|| "ensure_model_profile requires profileId".to_string())?; + Ok(vec![recipe_action_internal_command( + format!("Prepare model access: {}", profile_id), + INTERNAL_ENSURE_MODEL_PROFILE_COMMAND, + json!({ "profileId": profile_id }), + )?]) + } + "delete_model_profile" => { + let profile_id = action_string(args.get("profileId")) + .ok_or_else(|| "delete_model_profile requires profileId".to_string())?; + let delete_auth_ref = action_bool(args.get("deleteAuthRef")); + let profiles = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_model_profiles_with_pool(pool, host_id).await? + } + _ => { + let paths = resolve_paths(); + load_model_profiles(&paths) + } + }; + let profile = profiles + .iter() + .find(|profile| profile.id == profile_id) + .ok_or_else(|| format!("Model profile '{}' was not found", profile_id))?; + let cfg = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_read_openclaw_config_text_and_json(pool, &host_id) + .await? + .2 + } + _ => { + let paths = resolve_paths(); + read_openclaw_config(&paths)? + } + }; + let bindings = collect_model_bindings(&cfg, &profiles); + if bindings + .iter() + .any(|binding| binding.model_profile_id.as_deref() == Some(profile_id.as_str())) + { + return Err(format!( + "Model profile '{}' is still referenced by at least one model binding", + profile_id + )); + } + Ok(vec![recipe_action_internal_command( + format!("Remove model access: {}", profile_id), + INTERNAL_DELETE_MODEL_PROFILE_COMMAND, + json!({ + "profileId": profile_id, + "deleteAuthRef": delete_auth_ref, + "authRef": auth_ref_for_runtime_profile(profile), + }), + )?]) + } + "ensure_provider_auth" => { + let provider = action_string(args.get("provider")) + .ok_or_else(|| "ensure_provider_auth requires provider".to_string())?; + let auth_ref = action_string(args.get("authRef")) + .unwrap_or_else(|| format!("{}:default", provider.trim().to_ascii_lowercase())); + Ok(vec![recipe_action_internal_command( + format!("Prepare provider auth: {}", provider), + INTERNAL_ENSURE_PROVIDER_AUTH_COMMAND, + json!({ + "provider": provider, + "authRef": auth_ref, + }), + )?]) + } + "delete_provider_auth" => { + let auth_ref = action_string(args.get("authRef")) + .ok_or_else(|| "delete_provider_auth requires authRef".to_string())?; + let force = action_bool(args.get("force")); + let profiles = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_list_model_profiles_with_pool(pool, host_id).await? + } + _ => { + let paths = resolve_paths(); + load_model_profiles(&paths) + } + }; + let cfg = match route.runner.as_str() { + "remote_ssh" => { + let host_id = route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + remote_read_openclaw_config_text_and_json(pool, &host_id) + .await? + .2 + } + _ => { + let paths = resolve_paths(); + read_openclaw_config(&paths)? + } + }; + let bindings = collect_model_bindings(&cfg, &profiles); + if !force && auth_ref_is_in_use_by_bindings(&profiles, &bindings, &auth_ref) { + return Err(format!( + "Provider auth '{}' is still referenced by at least one model binding", + auth_ref + )); + } + Ok(vec![recipe_action_internal_command( + format!("Remove provider auth: {}", auth_ref), + INTERNAL_DELETE_PROVIDER_AUTH_COMMAND, + json!({ + "authRef": auth_ref, + "force": force, + }), + )?]) + } + "list_channels" => { + let mut command = vec!["openclaw".into(), "channels".into(), "list".into()]; + if action_bool(args.get("noUsage")) { + command.push("--no-usage".into()); + } + Ok(vec![("List channels".into(), command)]) + } + "channels_status" => Ok(vec![( + "Inspect channel status".into(), + vec!["openclaw".into(), "channels".into(), "status".into()], + )]), + "inspect_channel_capabilities" => { + let mut command = vec!["openclaw".into(), "channels".into(), "capabilities".into()]; + if let Some(channel) = action_string(args.get("channel")) { + command.push("--channel".into()); + command.push(channel); + } + if let Some(target) = action_string(args.get("target")) { + command.push("--target".into()); + command.push(target); + } + Ok(vec![("Inspect channel capabilities".into(), command)]) + } + "resolve_channel_targets" => { + let channel = action_string(args.get("channel")) + .ok_or_else(|| "resolve_channel_targets requires channel".to_string())?; + let terms = action_string_list(args.get("terms")); + if terms.is_empty() { + return Err("resolve_channel_targets requires at least one term".to_string()); + } + let mut command = vec![ + "openclaw".into(), + "channels".into(), + "resolve".into(), + "--channel".into(), + channel, + ]; + if let Some(kind) = action_string(args.get("kind")) { + command.push("--kind".into()); + command.push(kind); + } + command.extend(terms); + Ok(vec![("Resolve channel targets".into(), command)]) + } + "reload_secrets" => Ok(vec![( + "Reload secrets".into(), + vec!["openclaw".into(), "secrets".into(), "reload".into()], + )]), + "audit_secrets" => { + let mut command = vec!["openclaw".into(), "secrets".into(), "audit".into()]; + if action_bool(args.get("check")) { + command.push("--check".into()); + } + Ok(vec![("Audit secrets".into(), command)]) + } + "apply_secrets_plan" => { + let from_path = action_string(args.get("fromPath")) + .ok_or_else(|| "apply_secrets_plan requires fromPath".to_string())?; + let mut command = vec![ + "openclaw".into(), + "secrets".into(), + "apply".into(), + "--from".into(), + from_path.clone(), + ]; + if action_bool(args.get("dryRun")) { + command.push("--dry-run".into()); + } + if action_bool(args.get("jsonOutput")) { + command.push("--json".into()); + } + Ok(vec![( + format!("Apply secrets plan: {}", from_path), + command, + )]) + } + other => Err(format!("unsupported recipe action '{}'", other)), + } +} + +async fn materialize_recipe_commands( + spec: &crate::execution_spec::ExecutionSpec, + cache: &crate::cli_runner::CliCache, + pool: &SshConnectionPool, + route: &crate::recipe_executor::ExecutionRoute, +) -> Result)>, String> { + let mut commands = Vec::new(); + for action in &spec.actions { + commands.extend(materialize_recipe_action_commands(action, cache, pool, route).await?); + } + Ok(commands) +} + +#[cfg(test)] +mod recipe_action_materializer_tests { + use super::{ + materialize_recipe_action_commands, recipe_action_agent_persona_command, + recipe_action_markdown_document_command, recipe_action_setup_identity_command, + remove_binding_entries, resolve_openclaw_default_workspace_from_config, + INTERNAL_AGENT_PERSONA_COMMAND, INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND, + INTERNAL_SETUP_IDENTITY_COMMAND, + }; + use crate::{ + cli_runner::CliCache, execution_spec::ExecutionAction, recipe_executor::ExecutionRoute, + ssh::SshConnectionPool, + }; + use serde_json::{json, Value}; + + #[test] + fn setup_identity_materializes_to_internal_command() { + let (label, command) = + recipe_action_setup_identity_command("lobster", Some("Lobster"), Some("🦞"), None); + + assert_eq!(label, "Setup identity: lobster"); + assert_eq!(command[0], INTERNAL_SETUP_IDENTITY_COMMAND); + let payload: Value = serde_json::from_str(&command[1]).expect("identity payload"); + assert_eq!( + payload.get("agentId").and_then(Value::as_str), + Some("lobster") + ); + assert_eq!(payload.get("name").and_then(Value::as_str), Some("Lobster")); + assert_eq!(payload.get("emoji").and_then(Value::as_str), Some("🦞")); + } + + #[test] + fn setup_identity_materializes_to_internal_command_without_name() { + let (_label, command) = + recipe_action_setup_identity_command("lobster", None, None, Some("New persona")); + + assert_eq!(command[0], INTERNAL_SETUP_IDENTITY_COMMAND); + let payload: Value = serde_json::from_str(&command[1]).expect("identity payload"); + assert_eq!( + payload.get("agentId").and_then(Value::as_str), + Some("lobster") + ); + assert_eq!(payload.get("name"), None); + assert_eq!( + payload.get("persona").and_then(Value::as_str), + Some("New persona") + ); + } + + #[test] + fn set_agent_persona_materializes_to_internal_command() { + let (label, command) = + recipe_action_agent_persona_command("lobster", Some("Stay calm."), false) + .expect("agent persona command"); + + assert_eq!(label, "Update persona: lobster"); + assert_eq!(command[0], INTERNAL_AGENT_PERSONA_COMMAND); + let payload: Value = serde_json::from_str(&command[1]).expect("agent persona payload"); + assert_eq!( + payload.get("agentId").and_then(Value::as_str), + Some("lobster") + ); + assert_eq!( + payload.get("persona").and_then(Value::as_str), + Some("Stay calm.") + ); + } + + #[test] + fn markdown_document_write_materializes_to_internal_command() { + let args = serde_json::from_value(json!({ + "target": { "scope": "agent", "agentId": "lobster", "path": "PLAYBOOK.md" }, + "mode": "replace", + "content": "# Playbook\n" + })) + .expect("markdown args"); + + let (label, command) = recipe_action_markdown_document_command( + "Write playbook", + INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND, + &args, + ) + .expect("markdown command"); + + assert_eq!(label, "Write playbook"); + assert_eq!(command[0], INTERNAL_MARKDOWN_DOCUMENT_WRITE_COMMAND); + let payload: Value = serde_json::from_str(&command[1]).expect("markdown payload"); + assert_eq!( + payload.pointer("/target/agentId").and_then(Value::as_str), + Some("lobster") + ); + } + + #[tokio::test] + async fn set_channel_persona_materialization_preserves_trailing_newline() { + let action = ExecutionAction { + kind: Some("set_channel_persona".into()), + name: Some("Apply channel persona preset".into()), + args: json!({ + "channelType": "discord", + "guildId": "guild-1", + "peerId": "channel-1", + "persona": "Line one\n\nLine two\n" + }), + }; + + let cache = CliCache::new(); + let pool = SshConnectionPool::default(); + let route = ExecutionRoute { + runner: "local".into(), + target_kind: "local".into(), + host_id: None, + }; + + let commands = materialize_recipe_action_commands(&action, &cache, &pool, &route) + .await + .expect("materialize channel persona action"); + + let payload = commands + .iter() + .find(|(_, command)| { + command.len() >= 5 + && command[0] == "openclaw" + && command[1] == "config" + && command[2] == "set" + && command[3] + == "channels.discord.guilds.guild-1.channels.channel-1.systemPrompt" + }) + .map(|(_, command)| command[4].clone()) + .expect("systemPrompt config set command"); + + assert_eq!(payload, "\"Line one\\n\\nLine two\\n\""); + } + + #[tokio::test] + async fn set_agent_identity_materializes_to_openclaw_cli_command() { + let action = ExecutionAction { + kind: Some("set_agent_identity".into()), + name: Some("Set identity".into()), + args: json!({ + "agentId": "lobster", + "name": "Lobster", + "theme": "sea captain", + "emoji": "🦞", + "avatar": "avatars/lobster.png" + }), + }; + + let cache = CliCache::new(); + let pool = SshConnectionPool::default(); + let route = ExecutionRoute { + runner: "local".into(), + target_kind: "local".into(), + host_id: None, + }; + + let commands = materialize_recipe_action_commands(&action, &cache, &pool, &route) + .await + .expect("materialize set_agent_identity"); + + assert_eq!( + commands, + vec![( + "Set identity".into(), + vec![ + "openclaw".into(), + "agents".into(), + "set-identity".into(), + "--agent".into(), + "lobster".into(), + "--name".into(), + "Lobster".into(), + "--theme".into(), + "sea captain".into(), + "--emoji".into(), + "🦞".into(), + "--avatar".into(), + "avatars/lobster.png".into(), + ], + )] + ); + } + + #[test] + fn resolve_openclaw_default_workspace_prefers_defaults_before_existing_agents() { + let cfg = json!({ + "agents": { + "defaults": { + "workspace": "~/.openclaw/instances/demo/workspace" + }, + "list": [ + { "id": "main", "workspace": "/tmp/other" } + ] + } + }); + + assert_eq!( + resolve_openclaw_default_workspace_from_config(&cfg).as_deref(), + Some("~/.openclaw/instances/demo/workspace") + ); + } + + #[tokio::test] + async fn bind_agent_materializes_to_openclaw_cli_command() { + let action = ExecutionAction { + kind: Some("bind_agent".into()), + name: Some("Bind support".into()), + args: json!({ + "agentId": "ops", + "binding": "discord:channel-1" + }), + }; + + let cache = CliCache::new(); + let pool = SshConnectionPool::default(); + let route = ExecutionRoute { + runner: "local".into(), + target_kind: "local".into(), + host_id: None, + }; + + let commands = materialize_recipe_action_commands(&action, &cache, &pool, &route) + .await + .expect("materialize bind_agent"); + + assert_eq!( + commands[0].1, + vec![ + "openclaw", + "agents", + "bind", + "--agent", + "ops", + "--bind", + "discord:channel-1", + ] + ); + } + + #[tokio::test] + async fn resolve_channel_targets_materializes_terms_and_kind() { + let action = ExecutionAction { + kind: Some("resolve_channel_targets".into()), + name: Some("Resolve Slack room".into()), + args: json!({ + "channel": "slack", + "kind": "group", + "terms": ["#general", "@jane"] + }), + }; + + let cache = CliCache::new(); + let pool = SshConnectionPool::default(); + let route = ExecutionRoute { + runner: "local".into(), + target_kind: "local".into(), + host_id: None, + }; + + let commands = materialize_recipe_action_commands(&action, &cache, &pool, &route) + .await + .expect("materialize resolve_channel_targets"); + + assert_eq!( + commands[0].1, + vec![ + "openclaw", + "channels", + "resolve", + "--channel", + "slack", + "--kind", + "group", + "#general", + "@jane", + ] + ); + } + + #[tokio::test] + async fn unsupported_catalog_action_fails_fast() { + let action = ExecutionAction { + kind: Some("configure_secrets".into()), + name: Some("Configure secrets".into()), + args: json!({}), + }; + + let cache = CliCache::new(); + let pool = SshConnectionPool::default(); + let route = ExecutionRoute { + runner: "local".into(), + target_kind: "local".into(), + host_id: None, + }; + + let error = materialize_recipe_action_commands(&action, &cache, &pool, &route) + .await + .expect_err("interactive action should fail"); + + assert!(error.contains("documented but not supported")); + } + + #[test] + fn remove_binding_entries_drops_matching_channel_binding() { + let next = remove_binding_entries( + vec![ + json!({ + "agentId": "lobster", + "match": { + "channel": "discord", + "peer": { "kind": "channel", "id": "channel-1" } + } + }), + json!({ + "agentId": "ops", + "match": { + "channel": "discord", + "peer": { "kind": "channel", "id": "channel-2" } + } + }), + ], + "discord", + "channel-1", + ); + + assert_eq!(next.len(), 1); + assert_eq!(next[0].get("agentId").and_then(Value::as_str), Some("ops")); + } +} + +#[cfg(test)] +mod model_value_resolution_tests { + use super::{profile_to_model_value, resolve_model_value_from_profiles, ModelProfile}; + + fn profile(id: &str, provider: &str, model: &str) -> ModelProfile { + ModelProfile { + id: id.to_string(), + name: format!("{provider}/{model}"), + provider: provider.to_string(), + model: model.to_string(), + auth_ref: format!("{provider}:default"), + api_key: None, + base_url: None, + description: None, + enabled: true, + } + } + + #[test] + fn resolve_model_value_maps_profile_id_to_model_value() { + let profiles = vec![profile("remote-openai", "openai", "gpt-4o")]; + + let resolved = resolve_model_value_from_profiles(&profiles, "remote-openai") + .expect("profile should resolve"); + + assert_eq!(resolved, Some(profile_to_model_value(&profiles[0]))); + } + + #[test] + fn resolve_model_value_rejects_unknown_profile_ids() { + let profiles = vec![profile("remote-openai", "openai", "gpt-4o")]; + + let error = + resolve_model_value_from_profiles(&profiles, "b176e1fe-71b7-42ca-b9ad-96d8e15edf77") + .expect_err("unknown profile ids should be rejected"); + + assert!(error.contains("Model profile is not available on this instance")); + } +} + +#[cfg(test)] +mod runtime_artifact_tests { + use crate::execution_spec::{ + ExecutionAction, ExecutionCapabilities, ExecutionMetadata, ExecutionResourceClaim, + ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, + }; + use crate::recipe_executor::{ + build_runtime_artifacts, execute_recipe as prepare_recipe_execution, ExecuteRecipeRequest, + }; + use serde_json::json; + + fn sample_schedule_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-reconcile".into()), + digest: None, + }, + source: serde_json::Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "schedule".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("schedule/hourly".into()), + target: Some("job/hourly-reconcile".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "schedule": { + "id": "schedule/hourly", + "onCalendar": "hourly", + }, + "job": { + "command": ["openclaw", "doctor", "run"], + } + }), + actions: vec![ExecutionAction { + kind: Some("schedule".into()), + name: Some("Run hourly reconcile".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + "onCalendar": "hourly", + }), + }], + outputs: vec![], + } + } + + #[test] + fn build_runtime_artifacts_tracks_schedule_timer_units() { + let spec = sample_schedule_spec(); + let prepared = prepare_recipe_execution(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare recipe execution"); + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdUnit")); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdTimer")); + } +} + +pub async fn execute_recipe_with_services( + queue: &crate::cli_runner::CommandQueue, + cache: &crate::cli_runner::CliCache, + pool: &SshConnectionPool, + remote_queues: &crate::cli_runner::RemoteCommandQueues, + mut request: ExecuteRecipeRequest, +) -> Result { + if let Some(workspace_slug) = request + .workspace_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let workspace = RecipeWorkspace::from_resolved_paths(); + let source_kind = workspace + .workspace_source_kind(workspace_slug)? + .unwrap_or(crate::recipe_workspace::RecipeWorkspaceSourceKind::LocalImport); + let risk_level = workspace.workspace_risk_level(workspace_slug)?; + let current_source = request + .source_text + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .map(Ok) + .unwrap_or_else(|| workspace.read_recipe_source(workspace_slug))?; + let current_digest = RecipeWorkspace::source_digest(¤t_source); + + if approval_required_for(source_kind, risk_level) + && !workspace.is_recipe_approved(workspace_slug, ¤t_digest)? + { + return Err( + "This recipe needs your approval before it can run in this environment." + .to_string(), + ); + } + } + + let mut source = request.spec.source.as_object().cloned().unwrap_or_default(); + + if let Some(source_origin) = request + .source_origin + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + source.insert( + "recipeSourceOrigin".into(), + Value::String(source_origin.to_string()), + ); + } + + if let Some(source_text) = request + .source_text + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + source.insert( + "recipeSourceDigest".into(), + Value::String( + uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, source_text.as_bytes()).to_string(), + ), + ); + } + + if let Some(workspace_slug) = request + .workspace_slug + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Ok(path) = + RecipeWorkspace::from_resolved_paths().resolve_recipe_source_path(workspace_slug) + { + source.insert("recipeWorkspacePath".into(), Value::String(path)); + } + } + + if !source.is_empty() { + request.spec.source = Value::Object(source); + } + let spec = request.spec.clone(); + let prepared = prepare_recipe_execution(request)?; + let mut warnings = prepared.warnings.clone(); + let started_at = Utc::now().to_rfc3339(); + let summary = prepared.summary.clone(); + let runtime_artifacts = crate::recipe_executor::build_runtime_artifacts(&spec, &prepared); + + match prepared.route.runner.as_str() { + "local" => { + if !prepared.plan.commands.is_empty() { + crate::cli_runner::enqueue_materialized_plan(queue, &prepared.plan); + } else { + let commands = + materialize_recipe_commands(&spec, cache, pool, &prepared.route).await?; + if commands.is_empty() { + return Err("recipe did not materialize executable commands".into()); + } + for (label, command) in commands { + queue.enqueue(label, command); + } + } + let result = crate::cli_runner::apply_queued_commands_with_services( + queue, + cache, + Some(infer_recipe_id(&spec)), + Some(prepared.run_id.clone()), + Some(runtime_artifacts.clone()), + ) + .await?; + let finished_at = Utc::now().to_rfc3339(); + if !result.ok { + let error = result + .error + .unwrap_or_else(|| "recipe execution failed".to_string()); + warnings.extend(cleanup_local_recipe_artifacts(&runtime_artifacts)); + let _ = persist_recipe_run( + &spec, + &prepared, + "local", + "failed", + &error, + &started_at, + &finished_at, + &warnings, + ); + return Err(error); + } + + if let Err(error) = persist_recipe_run( + &spec, + &prepared, + "local", + "succeeded", + &summary, + &started_at, + &finished_at, + &warnings, + ) { + warnings.push(format!("Failed to persist recipe runtime state: {}", error)); + } + + Ok(ExecuteRecipeResult { + run_id: prepared.run_id, + instance_id: "local".into(), + summary, + warnings, + }) + } + "remote_ssh" => { + let host_id = prepared + .route + .host_id + .clone() + .ok_or_else(|| "remote execution target missing hostId".to_string())?; + if !prepared.plan.commands.is_empty() { + crate::cli_runner::enqueue_materialized_plan_remote( + remote_queues, + &host_id, + &prepared.plan, + ); + } else { + let commands = + materialize_recipe_commands(&spec, cache, pool, &prepared.route).await?; + if commands.is_empty() { + return Err("recipe did not materialize executable commands".into()); + } + for (label, command) in commands { + remote_queues.enqueue(&host_id, label, command); + } + } + let result = crate::cli_runner::remote_apply_queued_commands_with_services( + pool, + remote_queues, + host_id.clone(), + Some(infer_recipe_id(&spec)), + Some(prepared.run_id.clone()), + Some(runtime_artifacts.clone()), + ) + .await?; + let finished_at = Utc::now().to_rfc3339(); + if !result.ok { + let error = result + .error + .unwrap_or_else(|| "remote recipe execution failed".to_string()); + warnings.extend( + cleanup_remote_recipe_artifacts(&pool, &host_id, &runtime_artifacts).await, + ); + let _ = persist_recipe_run( + &spec, + &prepared, + &host_id, + "failed", + &error, + &started_at, + &finished_at, + &warnings, + ); + return Err(error); + } - Ok(messages) + if let Err(error) = persist_recipe_run( + &spec, + &prepared, + &host_id, + "succeeded", + &summary, + &started_at, + &finished_at, + &warnings, + ) { + warnings.push(format!("Failed to persist recipe runtime state: {}", error)); + } + + Ok(ExecuteRecipeResult { + run_id: prepared.run_id, + instance_id: host_id, + summary, + warnings, + }) + } + other => { + warnings.push(format!("route '{}' is not executable yet", other)); + Err(format!("unsupported execution runner: {}", other)) + } + } } #[tauri::command] -pub fn list_recipes(source: Option) -> Result, String> { - let paths = resolve_paths(); - let default_path = paths.clawpal_dir.join("recipes").join("recipes.json"); - Ok(load_recipes_with_fallback(source, &default_path)) +pub async fn execute_recipe( + queue: State<'_, crate::cli_runner::CommandQueue>, + cache: State<'_, crate::cli_runner::CliCache>, + pool: State<'_, SshConnectionPool>, + remote_queues: State<'_, crate::cli_runner::RemoteCommandQueues>, + request: ExecuteRecipeRequest, +) -> Result { + execute_recipe_with_services( + queue.inner(), + cache.inner(), + pool.inner(), + remote_queues.inner(), + request, + ) + .await } #[tauri::command] @@ -6049,6 +8386,502 @@ fn sync_main_auth_for_active_config(paths: &crate::models::OpenClawPaths) -> Res sync_main_auth_for_config(paths, &cfg) } +fn local_auth_store_path(paths: &crate::models::OpenClawPaths) -> PathBuf { + paths + .base_dir + .join("agents") + .join("main") + .join("agent") + .join("auth-profiles.json") +} + +fn parse_auth_store_json(raw: &str) -> Result { + serde_json::from_str(raw).map_err(|error| format!("Failed to parse auth store: {error}")) +} + +fn read_local_auth_store(paths: &crate::models::OpenClawPaths) -> Result { + let path = local_auth_store_path(paths); + let raw = + std::fs::read_to_string(&path).unwrap_or_else(|_| r#"{"version":1,"profiles":{}}"#.into()); + parse_auth_store_json(&raw) +} + +fn write_local_auth_store( + paths: &crate::models::OpenClawPaths, + auth_json: &Value, +) -> Result<(), String> { + let path = local_auth_store_path(paths); + let serialized = serde_json::to_string_pretty(auth_json).map_err(|error| error.to_string())?; + write_text(&path, &serialized) +} + +async fn remote_auth_store_path(pool: &SshConnectionPool, host_id: &str) -> Result { + let roots = resolve_remote_openclaw_roots(pool, host_id).await?; + let root = roots + .first() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Failed to resolve remote openclaw root".to_string())?; + Ok(format!( + "{}/agents/main/agent/auth-profiles.json", + root.trim_end_matches('/') + )) +} + +async fn read_remote_auth_store( + pool: &SshConnectionPool, + host_id: &str, +) -> Result<(String, Value), String> { + let path = remote_auth_store_path(pool, host_id).await?; + let raw = match pool.sftp_read(host_id, &path).await { + Ok(content) => content, + Err(error) if error.contains("No such file") || error.contains("not found") => { + r#"{"version":1,"profiles":{}}"#.to_string() + } + Err(error) => return Err(error), + }; + Ok((path, parse_auth_store_json(&raw)?)) +} + +async fn write_remote_auth_store( + pool: &SshConnectionPool, + host_id: &str, + path: &str, + auth_json: &Value, +) -> Result<(), String> { + let serialized = serde_json::to_string_pretty(auth_json).map_err(|error| error.to_string())?; + if let Some((dir, _)) = path.rsplit_once('/') { + let _ = pool + .exec(host_id, &format!("mkdir -p {}", shell_escape(dir))) + .await; + } + pool.sftp_write(host_id, path, &serialized).await +} + +fn upsert_auth_store_entry_internal( + root: &mut Value, + auth_ref: &str, + provider: &str, + credential: &InternalProviderCredential, +) -> Result { + if provider.trim().is_empty() { + return Err("provider is required".into()); + } + if !root.is_object() { + *root = json!({ "version": 1 }); + } + let root_obj = root + .as_object_mut() + .ok_or_else(|| "failed to prepare auth store".to_string())?; + if !root_obj.contains_key("version") { + root_obj.insert("version".into(), Value::from(1_u64)); + } + let profiles_value = root_obj + .entry("profiles".to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if !profiles_value.is_object() { + *profiles_value = Value::Object(serde_json::Map::new()); + } + let profiles = profiles_value + .as_object_mut() + .ok_or_else(|| "failed to prepare auth profiles".to_string())?; + let payload = match credential.kind { + InternalAuthKind::Authorization => json!({ + "type": "token", + "provider": provider, + "token": credential.secret, + }), + InternalAuthKind::ApiKey => json!({ + "type": "api_key", + "provider": provider, + "key": credential.secret, + }), + }; + let replace = profiles + .get(auth_ref) + .map(|existing| existing != &payload) + .unwrap_or(true); + if replace { + profiles.insert(auth_ref.to_string(), payload); + } + + let last_good_value = root_obj + .entry("lastGood".to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())); + if !last_good_value.is_object() { + *last_good_value = Value::Object(serde_json::Map::new()); + } + let last_good = last_good_value + .as_object_mut() + .ok_or_else(|| "failed to prepare lastGood auth mapping".to_string())?; + let provider_key = provider.trim().to_ascii_lowercase(); + let last_good_changed = last_good + .get(&provider_key) + .and_then(Value::as_str) + .map(|value| value != auth_ref) + .unwrap_or(true); + if last_good_changed { + last_good.insert(provider_key, Value::String(auth_ref.to_string())); + } + Ok(replace || last_good_changed) +} + +fn remove_auth_store_entry_internal(root: &mut Value, auth_ref: &str) -> bool { + let mut changed = false; + if let Some(profiles) = root.get_mut("profiles").and_then(Value::as_object_mut) { + changed |= profiles.remove(auth_ref).is_some(); + } + if let Some(last_good) = root.get_mut("lastGood").and_then(Value::as_object_mut) { + let providers_to_clear = last_good + .iter() + .filter_map(|(provider, value)| { + (value.as_str() == Some(auth_ref)).then_some(provider.clone()) + }) + .collect::>(); + for provider in providers_to_clear { + last_good.remove(&provider); + changed = true; + } + } + changed +} + +fn auth_ref_for_runtime_profile(profile: &ModelProfile) -> String { + profile_target_auth_ref(profile) +} + +fn auth_ref_is_in_use_by_bindings( + profiles: &[ModelProfile], + bindings: &[ModelBinding], + auth_ref: &str, +) -> bool { + bindings.iter().any(|binding| { + let Some(profile_id) = binding.model_profile_id.as_deref() else { + return false; + }; + profiles + .iter() + .find(|profile| profile.id == profile_id) + .map(|profile| auth_ref_for_runtime_profile(profile) == auth_ref) + .unwrap_or(false) + }) +} + +pub(crate) fn set_local_agent_model_for_recipe( + paths: &crate::models::OpenClawPaths, + agent_id: &str, + model_value: Option, +) -> Result<(), String> { + let mut cfg = read_openclaw_config(paths)?; + let current = serde_json::to_string_pretty(&cfg).map_err(|error| error.to_string())?; + set_agent_model_value(&mut cfg, agent_id, model_value)?; + write_config_with_snapshot(paths, ¤t, &cfg, "recipe-set-agent-model") +} + +pub(crate) async fn set_remote_agent_model_for_recipe( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, + model_value: Option, +) -> Result<(), String> { + let (config_path, current_text, mut cfg) = + remote_read_openclaw_config_text_and_json(pool, host_id).await?; + set_agent_model_value(&mut cfg, agent_id, model_value)?; + remote_write_config_with_snapshot( + pool, + host_id, + &config_path, + ¤t_text, + &cfg, + "recipe-set-agent-model", + ) + .await +} + +pub(crate) fn ensure_local_provider_auth_for_recipe( + paths: &crate::models::OpenClawPaths, + provider: &str, + auth_ref: Option<&str>, +) -> Result<(), String> { + let provider_key = provider.trim().to_ascii_lowercase(); + if provider_key.is_empty() { + return Err("provider is required".into()); + } + let credentials = collect_provider_credentials_from_paths(paths); + let credential = credentials.get(&provider_key).ok_or_else(|| { + format!( + "No local credential is available for provider '{}'", + provider_key + ) + })?; + let auth_ref = auth_ref + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{provider_key}:default")); + let mut auth_json = read_local_auth_store(paths)?; + if upsert_auth_store_entry_internal(&mut auth_json, &auth_ref, &provider_key, credential)? { + write_local_auth_store(paths, &auth_json)?; + } + Ok(()) +} + +pub(crate) async fn ensure_remote_provider_auth_for_recipe( + pool: &SshConnectionPool, + host_id: &str, + provider: &str, + auth_ref: Option<&str>, +) -> Result<(), String> { + let provider_key = provider.trim().to_ascii_lowercase(); + if provider_key.is_empty() { + return Err("provider is required".into()); + } + let paths = resolve_paths(); + let credentials = collect_provider_credentials_from_paths(&paths); + let credential = credentials.get(&provider_key).ok_or_else(|| { + format!( + "No local credential is available for provider '{}'", + provider_key + ) + })?; + let auth_ref = auth_ref + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{provider_key}:default")); + let (auth_path, mut auth_json) = read_remote_auth_store(pool, host_id).await?; + if upsert_auth_store_entry_internal(&mut auth_json, &auth_ref, &provider_key, credential)? { + write_remote_auth_store(pool, host_id, &auth_path, &auth_json).await?; + } + Ok(()) +} + +pub(crate) fn delete_local_provider_auth_for_recipe( + paths: &crate::models::OpenClawPaths, + auth_ref: &str, + force: bool, +) -> Result<(), String> { + let auth_ref = auth_ref.trim(); + if auth_ref.is_empty() { + return Err("authRef is required".into()); + } + let cfg = read_openclaw_config(paths)?; + let profiles = load_model_profiles(paths); + let bindings = collect_model_bindings(&cfg, &profiles); + if !force && auth_ref_is_in_use_by_bindings(&profiles, &bindings, auth_ref) { + return Err(format!( + "Provider auth '{}' is still referenced by at least one model binding", + auth_ref + )); + } + let mut auth_json = read_local_auth_store(paths)?; + if remove_auth_store_entry_internal(&mut auth_json, auth_ref) { + write_local_auth_store(paths, &auth_json)?; + } + Ok(()) +} + +pub(crate) async fn delete_remote_provider_auth_for_recipe( + pool: &SshConnectionPool, + host_id: &str, + auth_ref: &str, + force: bool, +) -> Result<(), String> { + let auth_ref = auth_ref.trim(); + if auth_ref.is_empty() { + return Err("authRef is required".into()); + } + let (_, _, cfg) = remote_read_openclaw_config_text_and_json(pool, host_id).await?; + let profiles = remote_list_model_profiles_with_pool(pool, host_id.to_string()).await?; + let bindings = collect_model_bindings(&cfg, &profiles); + if !force && auth_ref_is_in_use_by_bindings(&profiles, &bindings, auth_ref) { + return Err(format!( + "Provider auth '{}' is still referenced by at least one model binding", + auth_ref + )); + } + let (auth_path, mut auth_json) = read_remote_auth_store(pool, host_id).await?; + if remove_auth_store_entry_internal(&mut auth_json, auth_ref) { + write_remote_auth_store(pool, host_id, &auth_path, &auth_json).await?; + } + Ok(()) +} + +pub(crate) fn delete_local_model_profile_for_recipe( + paths: &crate::models::OpenClawPaths, + profile_id: &str, + delete_auth_ref: bool, +) -> Result<(), String> { + let cfg = read_openclaw_config(paths)?; + let profiles = load_model_profiles(paths); + let profile = profiles + .iter() + .find(|profile| profile.id == profile_id) + .cloned() + .ok_or_else(|| format!("Model profile '{}' was not found", profile_id))?; + let bindings = collect_model_bindings(&cfg, &profiles); + if bindings + .iter() + .any(|binding| binding.model_profile_id.as_deref() == Some(profile_id)) + { + return Err(format!( + "Model profile '{}' is still referenced by at least one model binding", + profile_id + )); + } + let mut next = cfg.clone(); + if let Some(models) = next.get_mut("models").and_then(Value::as_object_mut) { + models.remove(&profile_to_model_value(&profile)); + } + let current = serde_json::to_string_pretty(&cfg).map_err(|error| error.to_string())?; + write_config_with_snapshot(paths, ¤t, &next, "recipe-delete-model-profile")?; + if delete_auth_ref { + delete_local_provider_auth_for_recipe( + paths, + &auth_ref_for_runtime_profile(&profile), + false, + )?; + } + Ok(()) +} + +pub(crate) async fn delete_remote_model_profile_for_recipe( + pool: &SshConnectionPool, + host_id: &str, + profile_id: &str, + delete_auth_ref: bool, +) -> Result<(), String> { + let (config_path, current_text, cfg) = + remote_read_openclaw_config_text_and_json(pool, host_id).await?; + let profiles = remote_list_model_profiles_with_pool(pool, host_id.to_string()).await?; + let profile = profiles + .iter() + .find(|profile| profile.id == profile_id) + .cloned() + .ok_or_else(|| format!("Model profile '{}' was not found", profile_id))?; + let bindings = collect_model_bindings(&cfg, &profiles); + if bindings + .iter() + .any(|binding| binding.model_profile_id.as_deref() == Some(profile_id)) + { + return Err(format!( + "Model profile '{}' is still referenced by at least one model binding", + profile_id + )); + } + let mut next = cfg.clone(); + if let Some(models) = next.get_mut("models").and_then(Value::as_object_mut) { + models.remove(&profile_to_model_value(&profile)); + } + remote_write_config_with_snapshot( + pool, + host_id, + &config_path, + ¤t_text, + &next, + "recipe-delete-model-profile", + ) + .await?; + if delete_auth_ref { + delete_remote_provider_auth_for_recipe( + pool, + host_id, + &auth_ref_for_runtime_profile(&profile), + false, + ) + .await?; + } + Ok(()) +} + +pub(crate) fn delete_local_agent_for_recipe( + paths: &crate::models::OpenClawPaths, + agent_id: &str, + force: bool, + rebind_channels_to: Option<&str>, +) -> Result<(), String> { + if agent_id.trim().is_empty() { + return Err("agentId is required".into()); + } + let mut cfg = read_openclaw_config(paths)?; + let current = serde_json::to_string_pretty(&cfg).map_err(|error| error.to_string())?; + let bindings = cfg + .get("bindings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !force && rebind_channels_to.is_none() && bindings_reference_agent(&bindings, agent_id) { + return Err(format!( + "Agent '{}' is still referenced by at least one channel binding", + agent_id + )); + } + if let Some(list) = cfg + .pointer_mut("/agents/list") + .and_then(Value::as_array_mut) + { + let before = list.len(); + list.retain(|agent| agent.get("id").and_then(Value::as_str) != Some(agent_id)); + if before == list.len() { + return Err(format!("Agent '{}' not found", agent_id)); + } + } else { + return Err("agents.list not found".into()); + } + let next_bindings = rewrite_agent_bindings_for_delete(bindings, agent_id, rebind_channels_to); + set_nested_value(&mut cfg, "bindings", Some(Value::Array(next_bindings)))?; + write_config_with_snapshot(paths, ¤t, &cfg, "recipe-delete-agent") +} + +pub(crate) async fn delete_remote_agent_for_recipe( + pool: &SshConnectionPool, + host_id: &str, + agent_id: &str, + force: bool, + rebind_channels_to: Option<&str>, +) -> Result<(), String> { + if agent_id.trim().is_empty() { + return Err("agentId is required".into()); + } + let (config_path, current_text, mut cfg) = + remote_read_openclaw_config_text_and_json(pool, host_id).await?; + let bindings = cfg + .get("bindings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if !force && rebind_channels_to.is_none() && bindings_reference_agent(&bindings, agent_id) { + return Err(format!( + "Agent '{}' is still referenced by at least one channel binding", + agent_id + )); + } + if let Some(list) = cfg + .pointer_mut("/agents/list") + .and_then(Value::as_array_mut) + { + let before = list.len(); + list.retain(|agent| agent.get("id").and_then(Value::as_str) != Some(agent_id)); + if before == list.len() { + return Err(format!("Agent '{}' not found", agent_id)); + } + } else { + return Err("agents.list not found".into()); + } + let next_bindings = rewrite_agent_bindings_for_delete(bindings, agent_id, rebind_channels_to); + set_nested_value(&mut cfg, "bindings", Some(Value::Array(next_bindings)))?; + remote_write_config_with_snapshot( + pool, + host_id, + &config_path, + ¤t_text, + &cfg, + "recipe-delete-agent", + ) + .await +} + fn write_config_with_snapshot( paths: &crate::models::OpenClawPaths, current_text: &str, @@ -6063,6 +8896,8 @@ fn write_config_with_snapshot( true, current_text, None, + None, + Vec::new(), )?; write_json(&paths.config_path, next) } @@ -7393,6 +10228,7 @@ mod model_profile_upsert_tests { base_dir, history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), clawpal_dir, } } @@ -9742,7 +12578,7 @@ async fn remote_resolve_openclaw_config_path( Ok(path.to_string()) } -async fn remote_read_openclaw_config_text_and_json( +pub(crate) async fn remote_read_openclaw_config_text_and_json( pool: &SshConnectionPool, host_id: &str, ) -> Result<(String, String, Value), String> { diff --git a/src-tauri/src/commands/overview.rs b/src-tauri/src/commands/overview.rs index 3c33c4b7..01994225 100644 --- a/src-tauri/src/commands/overview.rs +++ b/src-tauri/src/commands/overview.rs @@ -66,7 +66,7 @@ fn extract_default_model_and_fallbacks(cfg: &Value) -> (Option, Vec Vec { +pub(crate) fn collect_agent_overviews_from_config(cfg: &Value) -> Vec { cfg.pointer("/agents/list") .and_then(Value::as_array) .map(|agents| { @@ -80,11 +80,13 @@ fn collect_agent_overviews_from_config(cfg: &Value) -> Vec { Some(AgentOverview { id, name: agent - .get("name") + .get("identityName") + .or_else(|| agent.get("name")) .and_then(Value::as_str) .map(|value| value.to_string()), emoji: agent - .get("emoji") + .get("identityEmoji") + .or_else(|| agent.get("emoji")) .and_then(Value::as_str) .map(|value| value.to_string()), model: agent.get("model").and_then(read_model_value), @@ -443,6 +445,29 @@ mod tests { assert!(!snapshot.agents[0].online); } + #[test] + fn agent_overviews_from_config_accept_identity_fields() { + let cfg = serde_json::json!({ + "agents": { + "list": [ + { + "id": "helper", + "identityName": "Helper", + "identityEmoji": "🛟", + "model": "openai/gpt-4o" + } + ] + } + }); + + let agents = collect_agent_overviews_from_config(&cfg); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].id, "helper"); + assert_eq!(agents[0].name.as_deref(), Some("Helper")); + assert_eq!(agents[0].emoji.as_deref(), Some("🛟")); + } + #[test] fn channels_config_snapshot_extracts_bindings_and_nodes() { let cfg = serde_json::json!({ diff --git a/src-tauri/src/commands/precheck.rs b/src-tauri/src/commands/precheck.rs index f5cbafa4..d355ebd6 100644 --- a/src-tauri/src/commands/precheck.rs +++ b/src-tauri/src/commands/precheck.rs @@ -3,6 +3,42 @@ use tauri::State; use crate::ssh::SshConnectionPool; +fn merge_auth_precheck_issues( + profiles: &[clawpal_core::profile::ModelProfile], + resolved_keys: &[super::ResolvedApiKey], +) -> Vec { + let mut issues = precheck::precheck_auth(profiles); + for profile in profiles { + if !profile.enabled { + continue; + } + if profile.provider.trim().is_empty() || profile.model.trim().is_empty() { + continue; + } + if super::provider_supports_optional_api_key(&profile.provider) { + continue; + } + + let resolved = resolved_keys + .iter() + .find(|item| item.profile_id == profile.id); + if resolved.is_some_and(|item| item.resolved) { + continue; + } + + issues.push(PrecheckIssue { + code: "AUTH_CREDENTIAL_UNRESOLVED".into(), + severity: "error".into(), + message: format!( + "Profile '{}' has no resolved credential for provider '{}'", + profile.id, profile.provider + ), + auto_fixable: false, + }); + } + issues +} + #[tauri::command] pub async fn precheck_registry() -> Result, String> { let registry_path = clawpal_core::instance::registry_path(); @@ -69,9 +105,91 @@ pub async fn precheck_transport( } #[tauri::command] -pub async fn precheck_auth(instance_id: String) -> Result, String> { - let openclaw = clawpal_core::openclaw::OpenclawCli::new(); - let profiles = clawpal_core::profile::list_profiles(&openclaw).map_err(|e| e.to_string())?; - let _ = instance_id; // reserved for future per-instance profile filtering - Ok(precheck::precheck_auth(&profiles)) +pub async fn precheck_auth( + pool: State<'_, SshConnectionPool>, + instance_id: String, +) -> Result, String> { + let registry = clawpal_core::instance::InstanceRegistry::load().map_err(|e| e.to_string())?; + let instance = registry + .get(&instance_id) + .ok_or_else(|| format!("Instance not found: {instance_id}"))?; + + match &instance.instance_type { + clawpal_core::instance::InstanceType::RemoteSsh => { + let (profiles, _) = + super::profiles::collect_remote_profiles_from_openclaw(&pool, &instance_id, true) + .await?; + let resolved = super::profiles::resolve_remote_api_keys_for_profiles( + &pool, + &instance_id, + &profiles, + ) + .await; + Ok(merge_auth_precheck_issues(&profiles, &resolved)) + } + _ => { + let openclaw = clawpal_core::openclaw::OpenclawCli::new(); + let profiles = + clawpal_core::profile::list_profiles(&openclaw).map_err(|e| e.to_string())?; + let resolved = super::resolve_api_keys()?; + Ok(merge_auth_precheck_issues(&profiles, &resolved)) + } + } +} + +#[cfg(test)] +mod tests { + use super::merge_auth_precheck_issues; + use crate::commands::{ResolvedApiKey, ResolvedCredentialKind}; + use clawpal_core::profile::ModelProfile; + + fn profile(id: &str, provider: &str, model: &str) -> ModelProfile { + ModelProfile { + id: id.into(), + name: format!("{provider}/{model}"), + provider: provider.into(), + model: model.into(), + auth_ref: "OPENAI_API_KEY".into(), + api_key: None, + base_url: None, + description: None, + enabled: true, + } + } + + #[test] + fn auth_precheck_detects_unresolved_required_credentials() { + let issues = merge_auth_precheck_issues( + &[profile("p1", "openai", "gpt-4o")], + &[ResolvedApiKey { + profile_id: "p1".into(), + masked_key: "not set".into(), + credential_kind: ResolvedCredentialKind::Unset, + auth_ref: Some("OPENAI_API_KEY".into()), + resolved: false, + }], + ); + + assert!(issues + .iter() + .any(|issue| issue.code == "AUTH_CREDENTIAL_UNRESOLVED")); + } + + #[test] + fn auth_precheck_skips_optional_api_key_providers() { + let issues = merge_auth_precheck_issues( + &[profile("p1", "ollama", "llama3")], + &[ResolvedApiKey { + profile_id: "p1".into(), + masked_key: "not set".into(), + credential_kind: ResolvedCredentialKind::Unset, + auth_ref: None, + resolved: false, + }], + ); + + assert!(!issues + .iter() + .any(|issue| issue.code == "AUTH_CREDENTIAL_UNRESOLVED")); + } } diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 150fb15d..8028dbaf 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -179,6 +179,7 @@ mod tests { clawpal_dir: clawpal_dir.clone(), history_dir: clawpal_dir.join("history"), metadata_path: clawpal_dir.join("metadata.json"), + recipe_runtime_dir: clawpal_dir.join("recipe-runtime"), }, root, ) diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 4d2d5a43..4baf7849 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -385,7 +385,7 @@ async fn read_remote_profiles_storage_text( } } -async fn collect_remote_profiles_from_openclaw( +pub(super) async fn collect_remote_profiles_from_openclaw( pool: &SshConnectionPool, host_id: &str, persist_storage: bool, @@ -410,13 +410,57 @@ async fn collect_remote_profiles_from_openclaw( Ok((next_profiles, result)) } +pub(super) async fn resolve_remote_api_keys_for_profiles( + pool: &SshConnectionPool, + host_id: &str, + profiles: &[ModelProfile], +) -> Vec { + let auth_cache = RemoteAuthCache::build(pool, host_id, profiles).await.ok(); + + let mut out = Vec::new(); + for profile in profiles { + let (resolved_key, source) = if let Some(ref cache) = auth_cache { + if let Some((key, source)) = cache.resolve_for_profile_with_source(profile) { + (key, Some(source)) + } else { + (String::new(), None) + } + } else { + match resolve_remote_profile_api_key(pool, host_id, profile).await { + Ok(key) => (key, None), + Err(_) => (String::new(), None), + } + }; + let resolved_override = if resolved_key.trim().is_empty() && oauth_session_ready(profile) { + Some(true) + } else { + None + }; + out.push(build_resolved_api_key( + profile, + &resolved_key, + source, + resolved_override, + )); + } + + out +} + +pub async fn remote_list_model_profiles_with_pool( + pool: &SshConnectionPool, + host_id: String, +) -> Result, String> { + let (profiles, _) = collect_remote_profiles_from_openclaw(pool, &host_id, true).await?; + Ok(profiles) +} + #[tauri::command] pub async fn remote_list_model_profiles( pool: State<'_, SshConnectionPool>, host_id: String, ) -> Result, String> { - let (profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?; - Ok(profiles) + remote_list_model_profiles_with_pool(pool.inner(), host_id).await } #[tauri::command] @@ -466,37 +510,7 @@ pub async fn remote_resolve_api_keys( host_id: String, ) -> Result, String> { let (profiles, _) = collect_remote_profiles_from_openclaw(&pool, &host_id, true).await?; - let auth_cache = RemoteAuthCache::build(&pool, &host_id, &profiles) - .await - .ok(); - - let mut out = Vec::new(); - for profile in &profiles { - let (resolved_key, source) = if let Some(ref cache) = auth_cache { - if let Some((key, source)) = cache.resolve_for_profile_with_source(profile) { - (key, Some(source)) - } else { - (String::new(), None) - } - } else { - match resolve_remote_profile_api_key(&pool, &host_id, profile).await { - Ok(key) => (key, None), - Err(_) => (String::new(), None), - } - }; - let resolved_override = if resolved_key.trim().is_empty() && oauth_session_ready(profile) { - Some(true) - } else { - None - }; - out.push(build_resolved_api_key( - profile, - &resolved_key, - source, - resolved_override, - )); - } - Ok(out) + Ok(resolve_remote_api_keys_for_profiles(&pool, &host_id, &profiles).await) } #[tauri::command] @@ -822,6 +836,11 @@ fn target_auth_ref_for_profile(profile: &ModelProfile, provider_key: &str) -> St format!("{provider_key}:default") } +pub(crate) fn profile_target_auth_ref(profile: &ModelProfile) -> String { + let provider_key = profile.provider.trim().to_ascii_lowercase(); + target_auth_ref_for_profile(profile, &provider_key) +} + fn prepare_profile_for_push( profile: &ModelProfile, source_base_dir: &Path, @@ -1069,7 +1088,14 @@ pub fn push_model_profiles_to_local_openclaw( profile_ids: Vec, ) -> Result { let paths = resolve_paths(); - let (prepared, blocked_profiles) = collect_selected_profile_pushes(&paths, &profile_ids)?; + ensure_local_model_profiles_internal(&paths, &profile_ids) +} + +pub(crate) fn ensure_local_model_profiles_internal( + paths: &crate::models::OpenClawPaths, + profile_ids: &[String], +) -> Result { + let (prepared, blocked_profiles) = collect_selected_profile_pushes(paths, profile_ids)?; if prepared.is_empty() { return Ok(ProfilePushResult { requested_profiles: profile_ids.len(), @@ -1141,9 +1167,17 @@ pub async fn push_model_profiles_to_remote_openclaw( pool: State<'_, SshConnectionPool>, host_id: String, profile_ids: Vec, +) -> Result { + ensure_remote_model_profiles_internal(pool.inner(), &host_id, &profile_ids).await +} + +pub(crate) async fn ensure_remote_model_profiles_internal( + pool: &SshConnectionPool, + host_id: &str, + profile_ids: &[String], ) -> Result { let paths = resolve_paths(); - let (prepared, blocked_profiles) = collect_selected_profile_pushes(&paths, &profile_ids)?; + let (prepared, blocked_profiles) = collect_selected_profile_pushes(&paths, profile_ids)?; if prepared.is_empty() { return Ok(ProfilePushResult { requested_profiles: profile_ids.len(), @@ -1155,7 +1189,7 @@ pub async fn push_model_profiles_to_remote_openclaw( } let (config_path, current_text, mut cfg) = - remote_read_openclaw_config_text_and_json(&pool, &host_id).await?; + remote_read_openclaw_config_text_and_json(pool, host_id).await?; let mut written_model_entries = 0usize; for push in &prepared { if upsert_model_registration(&mut cfg, push)? { @@ -1164,8 +1198,8 @@ pub async fn push_model_profiles_to_remote_openclaw( } if written_model_entries > 0 { remote_write_config_with_snapshot( - &pool, - &host_id, + pool, + host_id, &config_path, ¤t_text, &cfg, @@ -1174,7 +1208,7 @@ pub async fn push_model_profiles_to_remote_openclaw( .await?; } - let roots = resolve_remote_openclaw_roots(&pool, &host_id).await?; + let roots = resolve_remote_openclaw_roots(pool, host_id).await?; let root = roots .first() .map(String::as_str) @@ -1184,7 +1218,7 @@ pub async fn push_model_profiles_to_remote_openclaw( let root = root.trim_end_matches('/'); let remote_auth_dir = format!("{root}/agents/main/agent"); let remote_auth_path = format!("{remote_auth_dir}/auth-profiles.json"); - let remote_auth_raw = match pool.sftp_read(&host_id, &remote_auth_path).await { + let remote_auth_raw = match pool.sftp_read(host_id, &remote_auth_path).await { Ok(content) => content, Err(e) if is_remote_missing_path_error(&e) => r#"{"version":1,"profiles":{}}"#.to_string(), Err(e) => return Err(format!("Failed to read remote auth store: {e}")), @@ -1215,8 +1249,8 @@ pub async fn push_model_profiles_to_remote_openclaw( let serialized = serde_json::to_string_pretty(&remote_auth_json) .map_err(|e| format!("Failed to serialize remote auth store: {e}"))?; let mkdir_cmd = format!("mkdir -p {}", shell_escape(&remote_auth_dir)); - let _ = pool.exec(&host_id, &mkdir_cmd).await; - pool.sftp_write(&host_id, &remote_auth_path, &serialized) + let _ = pool.exec(host_id, &mkdir_cmd).await; + pool.sftp_write(host_id, &remote_auth_path, &serialized) .await?; } diff --git a/src-tauri/src/execution_spec.rs b/src-tauri/src/execution_spec.rs new file mode 100644 index 00000000..e5a25630 --- /dev/null +++ b/src-tauri/src/execution_spec.rs @@ -0,0 +1,187 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; + +use crate::recipe_bundle::{parse_structured_document, validate_execution_kind, RecipeBundle}; + +const SUPPORTED_RESOURCE_CLAIM_KINDS: &[&str] = &[ + "path", + "file", + "service", + "channel", + "agent", + "identity", + "document", + "modelProfile", + "authProfile", +]; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionMetadata { + pub name: Option, + pub digest: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionTarget { + pub kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionCapabilities { + pub used_capabilities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionResourceClaim { + pub kind: String, + pub id: Option, + pub target: Option, + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionResources { + pub claims: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSecretBinding { + pub id: String, + pub source: String, + pub mount: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSecrets { + pub bindings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionAction { + pub kind: Option, + pub name: Option, + pub args: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionSpec { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + pub metadata: ExecutionMetadata, + pub source: Value, + pub target: Value, + pub execution: ExecutionTarget, + pub capabilities: ExecutionCapabilities, + pub resources: ExecutionResources, + pub secrets: ExecutionSecrets, + pub desired_state: Value, + pub actions: Vec, + pub outputs: Vec, +} + +pub fn parse_execution_spec(raw: &str) -> Result { + let spec: ExecutionSpec = parse_structured_document(raw)?; + validate_execution_spec(&spec)?; + Ok(spec) +} + +pub fn validate_execution_spec(spec: &ExecutionSpec) -> Result<(), String> { + if spec.kind != "ExecutionSpec" { + return Err(format!("unsupported document kind: {}", spec.kind)); + } + + validate_execution_kind(&spec.execution.kind)?; + + for claim in &spec.resources.claims { + if !SUPPORTED_RESOURCE_CLAIM_KINDS.contains(&claim.kind.as_str()) { + return Err(format!( + "resource claim '{}' uses an unsupported kind", + claim.kind + )); + } + } + + for binding in &spec.secrets.bindings { + if binding.source.trim().starts_with("plain://") { + return Err(format!( + "secret binding '{}' uses a disallowed plain source", + binding.id + )); + } + } + + Ok(()) +} + +pub fn validate_execution_spec_against_bundle( + spec: &ExecutionSpec, + bundle: &RecipeBundle, +) -> Result<(), String> { + validate_execution_spec(spec)?; + + if !bundle.execution.supported_kinds.is_empty() + && !bundle + .execution + .supported_kinds + .iter() + .any(|kind| kind == &spec.execution.kind) + { + return Err(format!( + "execution kind '{}' is not supported by this bundle", + spec.execution.kind + )); + } + + let allowed_capabilities: BTreeSet<&str> = bundle + .capabilities + .allowed + .iter() + .map(String::as_str) + .collect(); + let unsupported_capabilities: Vec<&str> = spec + .capabilities + .used_capabilities + .iter() + .map(String::as_str) + .filter(|capability| !allowed_capabilities.contains(capability)) + .collect(); + if !unsupported_capabilities.is_empty() { + return Err(format!( + "execution spec uses capabilities not granted by bundle: {}", + unsupported_capabilities.join(", ") + )); + } + + let supported_resource_kinds: BTreeSet<&str> = bundle + .resources + .supported_kinds + .iter() + .map(String::as_str) + .collect(); + let unsupported_claims: Vec<&str> = spec + .resources + .claims + .iter() + .map(|claim| claim.kind.as_str()) + .filter(|kind| !supported_resource_kinds.contains(kind)) + .collect(); + if !unsupported_claims.is_empty() { + return Err(format!( + "execution spec declares claims for unsupported resource kinds: {}", + unsupported_claims.join(", ") + )); + } + + Ok(()) +} diff --git a/src-tauri/src/execution_spec_tests.rs b/src-tauri/src/execution_spec_tests.rs new file mode 100644 index 00000000..7f7a9c6d --- /dev/null +++ b/src-tauri/src/execution_spec_tests.rs @@ -0,0 +1,82 @@ +use crate::execution_spec::parse_execution_spec; +use crate::recipe_bundle::{parse_recipe_bundle, validate_execution_spec_against_bundle}; + +#[test] +fn execution_spec_rejects_inline_secret_value() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: job } +secrets: { bindings: [{ id: "k", source: "plain://abc" }] }"#; + + assert!(parse_execution_spec(raw).is_err()); +} + +#[test] +fn execution_spec_rejects_capabilities_outside_bundle_budget() { + let bundle_raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +capabilities: { allowed: ["service.manage"] } +resources: { supportedKinds: ["path"] } +execution: { supportedKinds: ["job"] }"#; + let spec_raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: "job" } +capabilities: { usedCapabilities: ["service.manage", "secret.read"] } +resources: { claims: [{ kind: "path", path: "/tmp/openclaw" }] }"#; + + let bundle = parse_recipe_bundle(bundle_raw).expect("parse bundle"); + let spec = parse_execution_spec(spec_raw).expect("parse spec"); + + assert!(validate_execution_spec_against_bundle(&bundle, &spec).is_err()); +} + +#[test] +fn execution_spec_rejects_unknown_resource_claim_kind() { + let bundle_raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +capabilities: { allowed: ["service.manage"] } +resources: { supportedKinds: ["path"] } +execution: { supportedKinds: ["job"] }"#; + let spec_raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: { kind: "job" } +capabilities: { usedCapabilities: ["service.manage"] } +resources: { claims: [{ kind: "file", path: "/tmp/app.sock" }] }"#; + + let bundle = parse_recipe_bundle(bundle_raw).expect("parse bundle"); + let spec = parse_execution_spec(spec_raw).expect("parse spec"); + + assert!(validate_execution_spec_against_bundle(&bundle, &spec).is_err()); +} + +#[test] +fn execution_spec_rejects_unknown_resource_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: + kind: job +resources: + claims: + - id: workspace + kind: workflow"#; + + assert!(parse_execution_spec(raw).is_err()); +} + +#[test] +fn execution_spec_accepts_recipe_runner_resource_claim_kinds() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: ExecutionSpec +execution: + kind: job +resources: + claims: + - kind: document + path: ~/.openclaw/agents/main/agent/IDENTITY.md + - kind: modelProfile + id: remote-openai + - kind: authProfile + id: openai:default"#; + + assert!(parse_execution_spec(raw).is_ok()); +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index da443df2..e42cb4cb 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -16,7 +16,11 @@ pub struct SnapshotMeta { pub source: String, pub can_rollback: bool, #[serde(skip_serializing_if = "Option::is_none", default)] + pub run_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] pub rollback_of: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, } #[derive(Debug, Serialize, Deserialize, Default)] @@ -24,6 +28,30 @@ pub struct SnapshotIndex { pub items: Vec, } +pub fn parse_snapshot_index_text(text: &str) -> Result { + if text.trim().is_empty() { + return Ok(SnapshotIndex::default()); + } + serde_json::from_str(text).map_err(|e| e.to_string()) +} + +pub fn render_snapshot_index_text(index: &SnapshotIndex) -> Result { + serde_json::to_string_pretty(index).map_err(|e| e.to_string()) +} + +pub fn upsert_snapshot(index: &mut SnapshotIndex, snapshot: SnapshotMeta) { + index.items.retain(|existing| existing.id != snapshot.id); + index.items.push(snapshot); + index.items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + if index.items.len() > 200 { + index.items.truncate(200); + } +} + +pub fn find_snapshot<'a>(index: &'a SnapshotIndex, snapshot_id: &str) -> Option<&'a SnapshotMeta> { + index.items.iter().find(|item| item.id == snapshot_id) +} + pub fn list_snapshots(path: &std::path::Path) -> Result { if !path.exists() { return Ok(SnapshotIndex { items: Vec::new() }); @@ -31,10 +59,7 @@ pub fn list_snapshots(path: &std::path::Path) -> Result { let mut file = File::open(path).map_err(|e| e.to_string())?; let mut text = String::new(); file.read_to_string(&mut text).map_err(|e| e.to_string())?; - if text.trim().is_empty() { - return Ok(SnapshotIndex { items: Vec::new() }); - } - serde_json::from_str(&text).map_err(|e| e.to_string()) + parse_snapshot_index_text(&text) } pub fn write_snapshots(path: &std::path::Path, index: &SnapshotIndex) -> Result<(), String> { @@ -42,7 +67,7 @@ pub fn write_snapshots(path: &std::path::Path, index: &SnapshotIndex) -> Result< .parent() .ok_or_else(|| "invalid metadata path".to_string())?; fs::create_dir_all(parent).map_err(|e| e.to_string())?; - let text = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?; + let text = render_snapshot_index_text(index)?; // Atomic write: write to .tmp file, sync, then rename let tmp = path.with_extension("tmp"); { @@ -60,7 +85,9 @@ pub fn add_snapshot( source: &str, rollbackable: bool, current_config: &str, + run_id: Option, rollback_of: Option, + artifacts: Vec, ) -> Result { fs::create_dir_all(paths).map_err(|e| e.to_string())?; @@ -80,19 +107,20 @@ pub fn add_snapshot( fs::write(&snapshot_path, current_config).map_err(|e| e.to_string())?; let mut next = index; - next.items.push(SnapshotMeta { - id: id.clone(), - recipe_id, - created_at: ts.clone(), - config_path: snapshot_path.to_string_lossy().to_string(), - source: source.to_string(), - can_rollback: rollbackable, - rollback_of: rollback_of.clone(), - }); - next.items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - if next.items.len() > 200 { - next.items.truncate(200); - } + upsert_snapshot( + &mut next, + SnapshotMeta { + id: id.clone(), + recipe_id, + created_at: ts.clone(), + config_path: snapshot_path.to_string_lossy().to_string(), + source: source.to_string(), + can_rollback: rollbackable, + run_id: run_id.clone(), + rollback_of: rollback_of.clone(), + artifacts: artifacts.clone(), + }, + ); write_snapshots(metadata_path, &next)?; let returned = Some(snapshot_recipe_id.clone()); @@ -104,7 +132,9 @@ pub fn add_snapshot( config_path: snapshot_path.to_string_lossy().to_string(), source: source.to_string(), can_rollback: rollbackable, + run_id, rollback_of, + artifacts, }) } @@ -120,13 +150,15 @@ pub fn read_snapshot(path: &str) -> Result { #[cfg(test)] mod tests { - use super::read_snapshot; - use crate::cli_runner::set_active_clawpal_data_override; + use super::{add_snapshot, list_snapshots, read_snapshot}; + use crate::cli_runner::{lock_active_override_test_state, set_active_clawpal_data_override}; + use crate::recipe_store::Artifact; use std::fs; use uuid::Uuid; #[test] fn read_snapshot_allows_files_under_active_history_dir() { + let _override_guard = lock_active_override_test_state(); let temp_root = std::env::temp_dir().join(format!("clawpal-history-{}", Uuid::new_v4())); let history_dir = temp_root.join("history"); fs::create_dir_all(&history_dir).expect("create history dir"); @@ -141,4 +173,44 @@ mod tests { assert_eq!(result.expect("read snapshot"), "{\"ok\":true}"); let _ = fs::remove_dir_all(temp_root); } + + #[test] + fn add_snapshot_persists_run_id_and_artifacts_in_metadata() { + let temp_root = std::env::temp_dir().join(format!("clawpal-history-{}", Uuid::new_v4())); + let history_dir = temp_root.join("history"); + let metadata_path = temp_root.join("metadata.json"); + + let snapshot = add_snapshot( + &history_dir, + &metadata_path, + Some("discord-channel-persona".into()), + "clawpal", + true, + "{\"ok\":true}", + Some("run_01".into()), + None, + vec![Artifact { + id: "artifact_01".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly.service".into(), + path: None, + }], + ) + .expect("write snapshot metadata"); + let index = list_snapshots(&metadata_path).expect("read snapshot metadata"); + + assert_eq!(snapshot.run_id.as_deref(), Some("run_01")); + assert_eq!( + index.items.first().and_then(|item| item.run_id.as_deref()), + Some("run_01") + ); + assert_eq!(snapshot.artifacts.len(), 1); + assert_eq!(snapshot.artifacts[0].label, "clawpal-job-hourly.service"); + assert_eq!( + index.items.first().map(|item| item.artifacts.len()), + Some(1) + ); + + let _ = fs::remove_dir_all(temp_root); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0491a7c..a2e6a4b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,32 +8,37 @@ use crate::cli_runner::{ remove_queued_command, CliCache, CommandQueue, RemoteCommandQueues, }; use crate::commands::{ - analyze_sessions, apply_config_patch, backup_before_upgrade, chat_via_openclaw, - check_openclaw_update, clear_all_sessions, clear_session_model_override, + analyze_sessions, apply_config_patch, approve_recipe_workspace_source, backup_before_upgrade, + chat_via_openclaw, check_openclaw_update, clear_all_sessions, clear_session_model_override, connect_docker_instance, connect_local_instance, connect_ssh_instance, create_agent, delete_agent, delete_backup, delete_cron_job, delete_local_instance_home, delete_model_profile, - delete_registered_instance, delete_sessions_by_ids, delete_ssh_host, deploy_watchdog, - diagnose_doctor_assistant, diagnose_primary_via_rescue, diagnose_ssh, discover_local_instances, - ensure_access_profile, extract_model_profiles_from_config, fix_issues, get_app_preferences, - get_bug_report_settings, get_cached_model_catalog, get_channels_config_snapshot, - get_channels_runtime_snapshot, get_cron_config_snapshot, get_cron_runs, - get_cron_runtime_snapshot, get_instance_config_snapshot, get_instance_runtime_snapshot, - get_rescue_bot_status, get_session_model_override, get_ssh_transfer_stats, get_status_extra, - get_status_light, get_system_status, get_watchdog_status, list_agents_overview, list_backups, - list_bindings, list_channels_minimal, list_cron_jobs, list_discord_guild_channels, - list_history, list_model_profiles, list_recipes, list_registered_instances, list_session_files, + delete_recipe_runs, delete_recipe_workspace_source, delete_registered_instance, + delete_sessions_by_ids, delete_ssh_host, deploy_watchdog, diagnose_doctor_assistant, + diagnose_primary_via_rescue, diagnose_ssh, discover_local_instances, ensure_access_profile, + execute_recipe, export_recipe_source, extract_model_profiles_from_config, fix_issues, + get_app_preferences, get_bug_report_settings, get_cached_model_catalog, + get_channels_config_snapshot, get_channels_runtime_snapshot, get_cron_config_snapshot, + get_cron_runs, get_cron_runtime_snapshot, get_instance_config_snapshot, + get_instance_runtime_snapshot, get_rescue_bot_status, get_session_model_override, + get_ssh_transfer_stats, get_status_extra, get_status_light, get_system_status, + get_watchdog_status, import_recipe_library, import_recipe_source, list_agents_overview, + list_backups, list_bindings, list_channels_minimal, list_cron_jobs, + list_discord_guild_channels, list_history, list_model_profiles, list_recipe_actions, + list_recipe_instances, list_recipe_runs, list_recipe_workspace_entries, list_recipes, + list_recipes_from_source_text, list_registered_instances, list_session_files, list_ssh_config_hosts, list_ssh_hosts, local_openclaw_cli_available, local_openclaw_config_exists, log_app_event, manage_rescue_bot, migrate_legacy_instances, - open_url, precheck_auth, precheck_instance, precheck_registry, precheck_transport, - preview_rollback, preview_session, probe_ssh_connection_profile, - push_model_profiles_to_local_openclaw, push_model_profiles_to_remote_openclaw, - push_related_secrets_to_remote, read_app_log, read_error_log, read_gateway_error_log, - read_gateway_log, read_helper_log, read_raw_config, record_install_experience, - refresh_discord_guild_channels, refresh_model_catalog, remote_analyze_sessions, - remote_apply_config_patch, remote_backup_before_upgrade, remote_chat_via_openclaw, - remote_check_openclaw_update, remote_clear_all_sessions, remote_delete_backup, - remote_delete_cron_job, remote_delete_model_profile, remote_delete_sessions_by_ids, - remote_deploy_watchdog, remote_diagnose_doctor_assistant, remote_diagnose_primary_via_rescue, + open_url, pick_recipe_source_directory, plan_recipe, plan_recipe_source, precheck_auth, + precheck_instance, precheck_registry, precheck_transport, preview_rollback, preview_session, + probe_ssh_connection_profile, push_model_profiles_to_local_openclaw, + push_model_profiles_to_remote_openclaw, push_related_secrets_to_remote, read_app_log, + read_error_log, read_gateway_error_log, read_gateway_log, read_helper_log, read_raw_config, + read_recipe_workspace_source, record_install_experience, refresh_discord_guild_channels, + refresh_model_catalog, remote_analyze_sessions, remote_apply_config_patch, + remote_backup_before_upgrade, remote_chat_via_openclaw, remote_check_openclaw_update, + remote_clear_all_sessions, remote_delete_backup, remote_delete_cron_job, + remote_delete_model_profile, remote_delete_sessions_by_ids, remote_deploy_watchdog, + remote_diagnose_doctor_assistant, remote_diagnose_primary_via_rescue, remote_extract_model_profiles_from_config, remote_fix_issues, remote_get_channels_config_snapshot, remote_get_channels_runtime_snapshot, remote_get_cron_config_snapshot, remote_get_cron_runs, remote_get_cron_runtime_snapshot, @@ -53,12 +58,13 @@ use crate::commands::{ remote_uninstall_watchdog, remote_upsert_model_profile, remote_write_raw_config, repair_doctor_assistant, repair_primary_via_rescue, resolve_api_keys, resolve_provider_auth, restart_gateway, restore_from_backup, rollback, run_doctor_command, run_openclaw_upgrade, - set_active_clawpal_data_dir, set_active_openclaw_home, set_agent_model, - set_bug_report_settings, set_global_model, set_session_model_override, + save_recipe_workspace_source, set_active_clawpal_data_dir, set_active_openclaw_home, + set_agent_model, set_bug_report_settings, set_global_model, set_session_model_override, set_ssh_transfer_speed_ui_preference, setup_agent_identity, sftp_list_dir, sftp_read_file, sftp_remove_file, sftp_write_file, ssh_connect, ssh_connect_with_passphrase, ssh_disconnect, ssh_exec, ssh_status, start_watchdog, stop_watchdog, test_model_profile, trigger_cron_job, - uninstall_watchdog, upsert_model_profile, upsert_ssh_host, + uninstall_watchdog, upgrade_bundled_recipe_workspace_source, upsert_model_profile, + upsert_ssh_host, validate_recipe_source_text, }; use crate::install::commands::{ install_create_session, install_decide_target, install_get_session, install_list_methods, @@ -70,26 +76,58 @@ use crate::ssh::SshConnectionPool; pub mod access_discovery; pub mod agent_fallback; +pub mod agent_identity; pub mod bridge_client; pub mod bug_report; pub mod cli_runner; pub mod commands; pub mod config_io; pub mod doctor; +pub mod execution_spec; pub mod history; pub mod install; pub mod json_util; pub mod logging; +pub mod markdown_document; pub mod models; pub mod node_client; pub mod openclaw_doc_resolver; pub mod path_fix; pub mod prompt_templates; pub mod recipe; +pub mod recipe_action_catalog; +pub mod recipe_adapter; +pub mod recipe_bundle; +pub mod recipe_executor; +pub mod recipe_library; +pub mod recipe_planner; +pub mod recipe_runtime; +pub mod recipe_store; +pub mod recipe_workspace; pub mod ssh; +#[cfg(test)] +mod execution_spec_tests; +#[cfg(test)] +mod recipe_adapter_tests; +#[cfg(test)] +mod recipe_bundle_tests; +#[cfg(test)] +mod recipe_executor_tests; +#[cfg(test)] +mod recipe_library_tests; +#[cfg(test)] +mod recipe_planner_tests; +#[cfg(test)] +mod recipe_source_tests; +#[cfg(test)] +mod recipe_store_tests; +#[cfg(test)] +mod recipe_workspace_tests; + pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(SshConnectionPool::new()) @@ -133,6 +171,25 @@ pub fn run() { get_session_model_override, clear_session_model_override, list_recipes, + list_recipes_from_source_text, + pick_recipe_source_directory, + list_recipe_actions, + validate_recipe_source_text, + list_recipe_workspace_entries, + read_recipe_workspace_source, + save_recipe_workspace_source, + approve_recipe_workspace_source, + import_recipe_library, + import_recipe_source, + delete_recipe_workspace_source, + upgrade_bundled_recipe_workspace_source, + export_recipe_source, + execute_recipe, + plan_recipe, + plan_recipe_source, + list_recipe_instances, + list_recipe_runs, + delete_recipe_runs, list_model_profiles, get_cached_model_catalog, refresh_model_catalog, @@ -302,7 +359,7 @@ pub fn run() { precheck_transport, precheck_auth, ]) - .setup(|_app| { + .setup(|app| { crate::bug_report::install_panic_hook(); let settings = crate::commands::preferences::load_bug_report_settings_from_paths( &crate::models::resolve_paths(), @@ -313,6 +370,9 @@ pub fn run() { if let Err(err) = crate::bug_report::queue::flush(&settings) { eprintln!("[bug-report] startup flush failed: {err}"); } + if let Err(err) = crate::recipe_library::seed_bundled_recipe_library(app.handle()) { + eprintln!("[recipe-library] bundled recipe seed failed: {err}"); + } // Run PATH fix in background so it doesn't block window creation. // openclaw commands won't fire until user interaction, giving this // plenty of time to complete. diff --git a/src-tauri/src/markdown_document.rs b/src-tauri/src/markdown_document.rs new file mode 100644 index 00000000..c0297cd6 --- /dev/null +++ b/src-tauri/src/markdown_document.rs @@ -0,0 +1,438 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use dirs::home_dir; +use serde::Deserialize; +use serde_json::Value; + +use crate::config_io::read_openclaw_config; +use crate::models::OpenClawPaths; +use crate::ssh::SshConnectionPool; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DocumentTarget { + scope: String, + #[serde(default)] + agent_id: Option, + path: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpsertDocumentPayload { + target: DocumentTarget, + content: String, + mode: String, + #[serde(default)] + heading: Option, + #[serde(default)] + create_if_missing: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteDocumentPayload { + target: DocumentTarget, + #[serde(default)] + missing_ok: Option, +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn validate_relative_path(path: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("document path is required".into()); + } + let candidate = Path::new(trimmed); + if candidate.is_absolute() { + return Err("document path must be relative for this target scope".into()); + } + for component in candidate.components() { + match component { + Component::Normal(_) => {} + _ => return Err("document path cannot escape its target scope".into()), + } + } + Ok(trimmed.to_string()) +} + +fn resolve_agent_entry<'a>(cfg: &'a Value, agent_id: &str) -> Result<&'a Value, String> { + let agents_list = cfg + .get("agents") + .and_then(|agents| agents.get("list")) + .and_then(Value::as_array) + .ok_or_else(|| "agents.list not found".to_string())?; + + agents_list + .iter() + .find(|agent| agent.get("id").and_then(Value::as_str) == Some(agent_id)) + .ok_or_else(|| format!("Agent '{}' not found", agent_id)) +} + +fn resolve_workspace( + cfg: &Value, + agent_id: &str, + default_workspace: Option<&str>, +) -> Result { + clawpal_core::doctor::resolve_agent_workspace_from_config(cfg, agent_id, default_workspace) +} + +fn push_unique_candidate(candidates: &mut Vec, candidate: Option) { + let Some(candidate) = candidate.map(|value| value.trim().to_string()) else { + return; + }; + if candidate.is_empty() || candidates.iter().any(|existing| existing == &candidate) { + return; + } + candidates.push(candidate); +} + +fn resolve_agent_dir_candidates( + cfg: &Value, + agent_id: &str, + fallback_agent_root: Option<&str>, +) -> Result, String> { + let agent = resolve_agent_entry(cfg, agent_id)?; + let mut candidates = Vec::new(); + + push_unique_candidate( + &mut candidates, + agent + .get("workspace") + .and_then(Value::as_str) + .map(str::to_string), + ); + push_unique_candidate( + &mut candidates, + agent + .get("agentDir") + .and_then(Value::as_str) + .map(str::to_string), + ); + push_unique_candidate(&mut candidates, resolve_workspace(cfg, agent_id, None).ok()); + push_unique_candidate( + &mut candidates, + fallback_agent_root + .map(|root| format!("{}/{}/agent", root.trim_end_matches('/'), agent_id)), + ); + + if candidates.is_empty() { + return Err(format!( + "Agent '{}' has no workspace or document directory configured", + agent_id + )); + } + + Ok(candidates) +} + +fn normalize_remote_dir(path: &str) -> String { + if path.starts_with("~/") || path.starts_with('/') { + path.to_string() + } else { + format!("~/{path}") + } +} + +fn resolve_local_target_path( + paths: &OpenClawPaths, + target: &DocumentTarget, +) -> Result { + let scope = target.scope.trim(); + match scope { + "agent" => { + let agent_id = normalize_optional_text(target.agent_id.as_deref()) + .ok_or_else(|| "agent document target requires agentId".to_string())?; + let relative = validate_relative_path(&target.path)?; + let cfg = read_openclaw_config(paths)?; + let fallback_root = paths + .openclaw_dir + .join("agents") + .to_string_lossy() + .to_string(); + let candidate_dirs = + resolve_agent_dir_candidates(&cfg, &agent_id, Some(&fallback_root))?; + let candidate_paths: Vec = candidate_dirs + .into_iter() + .map(|path| PathBuf::from(shellexpand::tilde(&path).to_string())) + .collect(); + if let Some(existing) = candidate_paths + .iter() + .map(|dir| dir.join(&relative)) + .find(|path| path.exists()) + { + return Ok(existing); + } + candidate_paths + .first() + .map(|dir| dir.join(relative)) + .ok_or_else(|| format!("Agent '{}' has no document path candidates", agent_id)) + } + "home" => { + let relative = target.path.trim().trim_start_matches("~/"); + let relative = validate_relative_path(relative)?; + let home = home_dir().ok_or_else(|| "failed to resolve home directory".to_string())?; + Ok(home.join(relative)) + } + "absolute" => { + let absolute = PathBuf::from(target.path.trim()); + if !absolute.is_absolute() { + return Err("absolute document targets must use an absolute path".into()); + } + Ok(absolute) + } + other => Err(format!("unsupported document target scope: {}", other)), + } +} + +async fn resolve_remote_target_path( + pool: &SshConnectionPool, + host_id: &str, + target: &DocumentTarget, +) -> Result { + let scope = target.scope.trim(); + match scope { + "agent" => { + let agent_id = normalize_optional_text(target.agent_id.as_deref()) + .ok_or_else(|| "agent document target requires agentId".to_string())?; + let relative = validate_relative_path(&target.path)?; + let (_config_path, _raw, cfg) = + crate::commands::remote_read_openclaw_config_text_and_json(pool, host_id).await?; + let candidate_dirs = + resolve_agent_dir_candidates(&cfg, &agent_id, Some("~/.openclaw/agents"))?; + let candidate_dirs: Vec = candidate_dirs + .into_iter() + .map(|dir| normalize_remote_dir(&dir)) + .collect(); + for dir in &candidate_dirs { + let candidate = format!("{dir}/{relative}"); + match pool.sftp_read(host_id, &candidate).await { + Ok(_) => return Ok(candidate), + Err(error) if error.contains("No such file") || error.contains("not found") => { + } + Err(error) => return Err(error), + } + } + candidate_dirs + .first() + .map(|dir| format!("{dir}/{relative}")) + .ok_or_else(|| format!("Agent '{}' has no document path candidates", agent_id)) + } + "home" => { + let relative = target.path.trim().trim_start_matches("~/"); + let relative = validate_relative_path(relative)?; + Ok(format!("~/{relative}")) + } + "absolute" => { + let absolute = target.path.trim(); + if !absolute.starts_with('/') { + return Err("absolute document targets must use an absolute path".into()); + } + Ok(absolute.to_string()) + } + other => Err(format!("unsupported document target scope: {}", other)), + } +} + +fn format_heading(heading: &str) -> String { + let trimmed = heading.trim(); + if trimmed.starts_with('#') { + trimmed.to_string() + } else { + format!("## {}", trimmed) + } +} + +pub(crate) fn upsert_markdown_section(existing: &str, heading: &str, content: &str) -> String { + let normalized = existing.replace("\r\n", "\n"); + let header = format_heading(heading); + let lines: Vec<&str> = normalized.lines().collect(); + let mut start = None; + let mut end = lines.len(); + + for (index, line) in lines.iter().enumerate() { + if line.trim() == header { + start = Some(index); + for (scan_index, candidate) in lines.iter().enumerate().skip(index + 1) { + if candidate.starts_with("## ") || candidate.starts_with("# ") { + end = scan_index; + break; + } + } + break; + } + } + + let replacement = if content.trim().is_empty() { + String::new() + } else { + format!("{header}\n{}\n", content.trim_end()) + }; + + if let Some(start) = start { + let before = if start == 0 { + String::new() + } else { + lines[..start].join("\n").trim_end().to_string() + }; + let after = if end >= lines.len() { + String::new() + } else { + lines[end..].join("\n").trim_start().to_string() + }; + let mut parts = Vec::new(); + if !before.is_empty() { + parts.push(before); + } + if !replacement.trim().is_empty() { + parts.push(replacement.trim_end().to_string()); + } + if !after.is_empty() { + parts.push(after); + } + return parts.join("\n\n") + "\n"; + } + + if normalized.trim().is_empty() { + return replacement; + } + + format!("{}\n\n{}", normalized.trim_end(), replacement) +} + +fn upsert_content( + existing: Option<&str>, + payload: &UpsertDocumentPayload, +) -> Result { + let mode = payload.mode.trim(); + match mode { + "replace" => Ok(payload.content.clone()), + "upsertSection" => { + let heading = payload + .heading + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "upsert_markdown_document requires heading in upsertSection mode".to_string() + })?; + let allow_create = payload.create_if_missing.unwrap_or(true); + let existing = existing.unwrap_or_default(); + if existing.trim().is_empty() && !allow_create { + return Err("document does not exist and createIfMissing is false".into()); + } + Ok(upsert_markdown_section(existing, heading, &payload.content)) + } + other => Err(format!("unsupported markdown document mode: {}", other)), + } +} + +pub(crate) fn write_local_markdown_document( + paths: &OpenClawPaths, + payload: &Value, +) -> Result<(), String> { + let payload: UpsertDocumentPayload = + serde_json::from_value(payload.clone()).map_err(|error| error.to_string())?; + let target_path = resolve_local_target_path(paths, &payload.target)?; + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let existing = fs::read_to_string(&target_path).ok(); + let next = upsert_content(existing.as_deref(), &payload)?; + fs::write(&target_path, next).map_err(|error| error.to_string())?; + Ok(()) +} + +pub(crate) async fn write_remote_markdown_document( + pool: &SshConnectionPool, + host_id: &str, + payload: &Value, +) -> Result<(), String> { + let payload: UpsertDocumentPayload = + serde_json::from_value(payload.clone()).map_err(|error| error.to_string())?; + let target_path = resolve_remote_target_path(pool, host_id, &payload.target).await?; + let existing = match pool.sftp_read(host_id, &target_path).await { + Ok(content) => Some(content), + Err(error) if error.contains("No such file") || error.contains("not found") => None, + Err(error) => return Err(error), + }; + let next = upsert_content(existing.as_deref(), &payload)?; + if let Some(parent) = target_path.rsplit_once('/') { + let _ = pool + .exec( + host_id, + &format!("mkdir -p '{}'", parent.0.replace('\'', "'\\''")), + ) + .await; + } + pool.sftp_write(host_id, &target_path, &next).await?; + Ok(()) +} + +pub(crate) fn delete_local_markdown_document( + paths: &OpenClawPaths, + payload: &Value, +) -> Result<(), String> { + let payload: DeleteDocumentPayload = + serde_json::from_value(payload.clone()).map_err(|error| error.to_string())?; + let target_path = resolve_local_target_path(paths, &payload.target)?; + match fs::remove_file(&target_path) { + Ok(_) => Ok(()), + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && payload.missing_ok.unwrap_or(true) => + { + Ok(()) + } + Err(error) => Err(error.to_string()), + } +} + +pub(crate) async fn delete_remote_markdown_document( + pool: &SshConnectionPool, + host_id: &str, + payload: &Value, +) -> Result<(), String> { + let payload: DeleteDocumentPayload = + serde_json::from_value(payload.clone()).map_err(|error| error.to_string())?; + let target_path = resolve_remote_target_path(pool, host_id, &payload.target).await?; + match pool.sftp_remove(host_id, &target_path).await { + Ok(_) => Ok(()), + Err(error) + if (error.contains("No such file") || error.contains("not found")) + && payload.missing_ok.unwrap_or(true) => + { + Ok(()) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::{upsert_markdown_section, validate_relative_path}; + + #[test] + fn relative_path_validation_rejects_parent_segments() { + assert!(validate_relative_path("../secrets.md").is_err()); + assert!(validate_relative_path("notes/../../secrets.md").is_err()); + } + + #[test] + fn upsert_section_replaces_existing_heading_block() { + let next = upsert_markdown_section( + "# Notes\n\n## Persona\nOld\n\n## Other\nStay\n", + "Persona", + "New", + ); + + assert_eq!(next, "# Notes\n\n## Persona\nNew\n\n## Other\nStay\n"); + } +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 0740c726..de294dfc 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -13,6 +13,7 @@ pub struct OpenClawPaths { pub clawpal_dir: PathBuf, pub history_dir: PathBuf, pub metadata_path: PathBuf, + pub recipe_runtime_dir: PathBuf, } fn expand_user_path(raw: &str) -> PathBuf { @@ -72,6 +73,7 @@ pub fn resolve_paths() -> OpenClawPaths { let config_path = openclaw_dir.join("openclaw.json"); let history_dir = clawpal_dir.join("history"); let metadata_path = clawpal_dir.join("metadata.json"); + let recipe_runtime_dir = clawpal_dir.join("recipe-runtime"); OpenClawPaths { openclaw_dir: openclaw_dir.clone(), @@ -80,5 +82,6 @@ pub fn resolve_paths() -> OpenClawPaths { clawpal_dir, history_dir, metadata_path, + recipe_runtime_dir, } } diff --git a/src-tauri/src/recipe.rs b/src-tauri/src/recipe.rs index 72a9d846..5fd1146b 100644 --- a/src-tauri/src/recipe.rs +++ b/src-tauri/src/recipe.rs @@ -6,15 +6,31 @@ use std::{ path::{Path, PathBuf}, }; +use crate::execution_spec::ExecutionSpec; +use crate::recipe_bundle::RecipeBundle; +use crate::{ + execution_spec::validate_execution_spec, + recipe_adapter::{build_recipe_spec_template, canonical_recipe_bundle}, + recipe_bundle::validate_execution_spec_against_bundle, +}; + const BUILTIN_RECIPES_JSON: &str = include_str!("../recipes.json"); #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] enum RecipeDocument { + Single(Recipe), List(Vec), Wrapped { recipes: Vec }, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecipeParamOption { + pub value: String, + pub label: String, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct RecipeParam { @@ -35,6 +51,8 @@ pub struct RecipeParam { pub depends_on: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -45,6 +63,13 @@ pub struct RecipeStep { pub args: Map, } +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct RecipePresentation { + #[serde(skip_serializing_if = "Option::is_none")] + pub result_summary: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct Recipe { @@ -54,8 +79,20 @@ pub struct Recipe { pub version: String, pub tags: Vec, pub difficulty: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub presentation: Option, pub params: Vec, pub steps: Vec, + #[serde( + rename = "clawpalPresetMaps", + skip_serializing_if = "Option::is_none", + default + )] + pub clawpal_preset_maps: Option>, + #[serde(skip_serializing, default)] + pub bundle: Option, + #[serde(skip_serializing, default)] + pub execution_spec_template: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -91,6 +128,27 @@ pub struct ApplyResult { pub errors: Vec, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceDiagnostic { + pub category: String, + pub severity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recipe_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceDiagnostics { + #[serde(default)] + pub errors: Vec, + #[serde(default)] + pub warnings: Vec, +} + pub fn builtin_recipes() -> Vec { parse_recipes_document(BUILTIN_RECIPES_JSON).unwrap_or_else(|_| Vec::new()) } @@ -111,11 +169,19 @@ fn expand_user_path(candidate: &str) -> PathBuf { fn parse_recipes_document(text: &str) -> Result, String> { let document: RecipeDocument = json5::from_str(text).map_err(|e| e.to_string())?; match document { + RecipeDocument::Single(recipe) => Ok(vec![recipe]), RecipeDocument::List(recipes) => Ok(recipes), RecipeDocument::Wrapped { recipes } => Ok(recipes), } } +pub fn load_recipes_from_source_text(text: &str) -> Result, String> { + if text.trim().is_empty() { + return Err("empty recipe source".into()); + } + parse_recipes_document(text) +} + pub fn load_recipes_from_source(source: &str) -> Result, String> { if source.trim().is_empty() { return Err("empty recipe source".into()); @@ -127,15 +193,20 @@ pub fn load_recipes_from_source(source: &str) -> Result, String> { return Err(format!("request failed: {}", response.status())); } let text = response.text().map_err(|e| e.to_string())?; - parse_recipes_document(&text) + load_recipes_from_source_text(&text) } else { let path = expand_user_path(source); let path = Path::new(&path); if !path.exists() { return Err(format!("recipe file not found: {}", path.to_string_lossy())); } + if path.is_dir() { + let (_, compiled_source) = + crate::recipe_library::compile_recipe_directory_source(path)?; + return load_recipes_from_source_text(&compiled_source); + } let text = fs::read_to_string(path).map_err(|e| e.to_string())?; - parse_recipes_document(&text) + load_recipes_from_source_text(&text) } } @@ -177,6 +248,84 @@ pub fn find_recipe_with_source(id: &str, source: Option) -> Option Result { + let mut diagnostics = RecipeSourceDiagnostics::default(); + let recipes = match load_recipes_from_source_text(text) { + Ok(recipes) => recipes, + Err(error) => { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "parse".into(), + severity: "error".into(), + recipe_id: None, + path: None, + message: error, + }); + return Ok(diagnostics); + } + }; + + for recipe in &recipes { + validate_recipe_definition(recipe, &mut diagnostics); + } + + Ok(diagnostics) +} + +fn validate_recipe_definition(recipe: &Recipe, diagnostics: &mut RecipeSourceDiagnostics) { + if let Some(template) = &recipe.execution_spec_template { + if template.actions.len() != recipe.steps.len() { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "alignment".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("steps".into()), + message: format!( + "recipe '{}' declares {} UI step(s) but {} execution action(s)", + recipe.id, + recipe.steps.len(), + template.actions.len() + ), + }); + } + } + + let spec = match build_recipe_spec_template(recipe) { + Ok(spec) => spec, + Err(error) => { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "schema".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("executionSpecTemplate".into()), + message: error, + }); + return; + } + }; + + if let Err(error) = validate_execution_spec(&spec) { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "schema".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("executionSpecTemplate".into()), + message: error, + }); + return; + } + + let bundle = canonical_recipe_bundle(recipe, &spec); + if let Err(error) = validate_execution_spec_against_bundle(&bundle, &spec) { + diagnostics.errors.push(RecipeSourceDiagnostic { + category: "bundle".into(), + severity: "error".into(), + recipe_id: Some(recipe.id.clone()), + path: Some("bundle".into()), + message: error, + }); + } +} + pub fn validate(recipe: &Recipe, params: &Map) -> Vec { let mut errors = Vec::new(); for p in &recipe.params { @@ -218,25 +367,147 @@ pub fn validate(recipe: &Recipe, params: &Map) -> Vec { errors } -fn render_patch_template(template: &str, params: &Map) -> String { +fn param_value_to_string(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + _ => value.to_string(), + } +} + +fn extract_placeholders(text: &str) -> Vec { + Regex::new(r"\{\{(?:(?:presetMap:)?(\w+))\}\}") + .ok() + .map(|regex| { + regex + .captures_iter(text) + .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_string())) + .collect() + }) + .unwrap_or_default() +} + +pub fn render_template_string(template: &str, params: &Map) -> String { let mut text = template.to_string(); for (k, v) in params { let placeholder = format!("{{{{{}}}}}", k); - let replacement = match v { - Value::String(s) => s.clone(), - _ => v.to_string(), - }; + let replacement = param_value_to_string(v); text = text.replace(&placeholder, &replacement); } text } +fn resolve_preset_map_value( + param_id: &str, + params: &Map, + preset_maps: Option<&Map>, +) -> Value { + let selected = params + .get(param_id) + .map(param_value_to_string) + .unwrap_or_default(); + preset_maps + .and_then(|maps| maps.get(param_id)) + .and_then(Value::as_object) + .and_then(|values| values.get(&selected)) + .cloned() + .unwrap_or_else(|| Value::String(String::new())) +} + +pub fn render_template_value( + value: &Value, + params: &Map, + preset_maps: Option<&Map>, +) -> Value { + match value { + Value::String(text) => { + if let Some(param_id) = text + .strip_prefix("{{presetMap:") + .and_then(|rest| rest.strip_suffix("}}")) + { + return resolve_preset_map_value(param_id, params, preset_maps); + } + if let Some(param_id) = text + .strip_prefix("{{") + .and_then(|rest| rest.strip_suffix("}}")) + { + if param_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + return params + .get(param_id) + .cloned() + .unwrap_or_else(|| Value::String(String::new())); + } + } + Value::String(render_template_string(text, params)) + } + Value::Array(items) => Value::Array( + items + .iter() + .map(|item| render_template_value(item, params, preset_maps)) + .collect(), + ), + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, value)| { + ( + render_template_string(key, params), + render_template_value(value, params, preset_maps), + ) + }) + .collect(), + ), + _ => value.clone(), + } +} + +pub fn render_step_args( + args: &Map, + params: &Map, + preset_maps: Option<&Map>, +) -> Map { + args.iter() + .map(|(key, value)| { + ( + key.clone(), + render_template_value(value, params, preset_maps), + ) + }) + .collect() +} + +pub fn step_references_empty_param(step: &RecipeStep, params: &Map) -> bool { + fn value_references_empty_param(value: &Value, params: &Map) -> bool { + match value { + Value::String(text) => extract_placeholders(text).into_iter().any(|param_id| { + params + .get(¶m_id) + .and_then(Value::as_str) + .map(|value| value.trim().is_empty()) + .unwrap_or(false) + }), + Value::Array(items) => items + .iter() + .any(|item| value_references_empty_param(item, params)), + Value::Object(map) => map + .values() + .any(|item| value_references_empty_param(item, params)), + _ => false, + } + } + + step.args + .values() + .any(|value| value_references_empty_param(value, params)) +} + pub fn build_candidate_config_from_template( current: &Value, template: &str, params: &Map, ) -> Result<(Value, Vec), String> { - let rendered = render_patch_template(template, params); + let rendered = render_template_string(template, params); let patch: Value = json5::from_str(&rendered).map_err(|e| e.to_string())?; let mut merged = current.clone(); let mut changes = Vec::new(); diff --git a/src-tauri/src/recipe_action_catalog.rs b/src-tauri/src/recipe_action_catalog.rs new file mode 100644 index 00000000..7b05a563 --- /dev/null +++ b/src-tauri/src/recipe_action_catalog.rs @@ -0,0 +1,631 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipeActionCatalogEntry { + pub kind: String, + pub title: String, + pub group: String, + pub category: String, + pub backend: String, + pub description: String, + pub read_only: bool, + pub interactive: bool, + pub runner_supported: bool, + pub recommended: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub cli_command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub legacy_alias_of: Option, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub resource_kinds: Vec, +} + +impl RecipeActionCatalogEntry { + fn new( + kind: &str, + title: &str, + group: &str, + category: &str, + backend: &str, + description: &str, + ) -> Self { + Self { + kind: kind.into(), + title: title.into(), + group: group.into(), + category: category.into(), + backend: backend.into(), + description: description.into(), + read_only: false, + interactive: false, + runner_supported: true, + recommended: false, + cli_command: None, + legacy_alias_of: None, + capabilities: Vec::new(), + resource_kinds: Vec::new(), + } + } + + fn read_only(mut self) -> Self { + self.read_only = true; + self + } + + fn interactive(mut self) -> Self { + self.interactive = true; + self.runner_supported = false; + self + } + + fn unsupported(mut self) -> Self { + self.runner_supported = false; + self + } + + fn recommended(mut self) -> Self { + self.recommended = true; + self + } + + fn cli(mut self, cli_command: &str) -> Self { + self.cli_command = Some(cli_command.into()); + self + } + + fn alias_of(mut self, kind: &str) -> Self { + self.legacy_alias_of = Some(kind.into()); + self + } + + fn capabilities(mut self, capabilities: &[&str]) -> Self { + self.capabilities = capabilities.iter().map(|item| item.to_string()).collect(); + self + } + + fn resource_kinds(mut self, kinds: &[&str]) -> Self { + self.resource_kinds = kinds.iter().map(|item| item.to_string()).collect(); + self + } +} + +pub fn list_recipe_actions() -> Vec { + vec![ + RecipeActionCatalogEntry::new( + "create_agent", + "Create agent", + "business", + "agents", + "openclaw_cli", + "Create a new OpenClaw agent.", + ) + .cli("openclaw agents add") + .recommended() + .capabilities(&["agent.manage"]) + .resource_kinds(&["agent"]), + RecipeActionCatalogEntry::new( + "delete_agent", + "Delete agent", + "business", + "agents", + "openclaw_cli", + "Delete an OpenClaw agent after binding safety checks.", + ) + .cli("openclaw agents delete") + .recommended() + .capabilities(&["agent.manage"]) + .resource_kinds(&["agent", "channel"]), + RecipeActionCatalogEntry::new( + "bind_agent", + "Bind agent", + "business", + "agents", + "openclaw_cli", + "Bind a channel routing target to an agent using OpenClaw binding syntax.", + ) + .cli("openclaw agents bind") + .recommended() + .capabilities(&["binding.manage"]) + .resource_kinds(&["agent", "channel"]), + RecipeActionCatalogEntry::new( + "unbind_agent", + "Unbind agent", + "business", + "agents", + "openclaw_cli", + "Remove one or all routing bindings from an agent.", + ) + .cli("openclaw agents unbind") + .recommended() + .capabilities(&["binding.manage"]) + .resource_kinds(&["agent", "channel"]), + RecipeActionCatalogEntry::new( + "set_agent_identity", + "Set agent identity", + "business", + "agents", + "openclaw_cli", + "Update an agent identity using OpenClaw identity fields.", + ) + .cli("openclaw agents set-identity") + .recommended() + .capabilities(&["agent.identity.write"]) + .resource_kinds(&["agent"]), + RecipeActionCatalogEntry::new( + "set_agent_model", + "Set agent model", + "business", + "models", + "orchestrated", + "Set an agent model after ensuring the target model profile exists.", + ) + .recommended() + .capabilities(&["model.manage", "secret.sync"]) + .resource_kinds(&["agent", "modelProfile"]), + RecipeActionCatalogEntry::new( + "set_agent_persona", + "Set agent persona", + "business", + "agents", + "clawpal_fallback", + "Update the persona section in an agent markdown document.", + ) + .recommended() + .capabilities(&["agent.identity.write"]) + .resource_kinds(&["agent"]), + RecipeActionCatalogEntry::new( + "clear_agent_persona", + "Clear agent persona", + "business", + "agents", + "clawpal_fallback", + "Remove the persona section from an agent markdown document.", + ) + .recommended() + .capabilities(&["agent.identity.write"]) + .resource_kinds(&["agent"]), + RecipeActionCatalogEntry::new( + "set_channel_persona", + "Set channel persona", + "business", + "channels", + "openclaw_cli", + "Set the systemPrompt for a channel through OpenClaw config.", + ) + .recommended() + .capabilities(&["config.write"]) + .resource_kinds(&["channel"]), + RecipeActionCatalogEntry::new( + "clear_channel_persona", + "Clear channel persona", + "business", + "channels", + "openclaw_cli", + "Clear the systemPrompt for a channel through OpenClaw config.", + ) + .recommended() + .capabilities(&["config.write"]) + .resource_kinds(&["channel"]), + RecipeActionCatalogEntry::new( + "upsert_markdown_document", + "Upsert markdown document", + "document", + "documents", + "clawpal_fallback", + "Write or update a text/markdown document using a controlled document target.", + ) + .capabilities(&["document.write"]) + .resource_kinds(&["document"]), + RecipeActionCatalogEntry::new( + "delete_markdown_document", + "Delete markdown document", + "document", + "documents", + "clawpal_fallback", + "Delete a text/markdown document using a controlled document target.", + ) + .capabilities(&["document.delete"]) + .resource_kinds(&["document"]), + RecipeActionCatalogEntry::new( + "ensure_model_profile", + "Ensure model profile", + "environment", + "models", + "orchestrated", + "Ensure a model profile and its dependent auth are available in the target environment.", + ) + .recommended() + .capabilities(&["model.manage", "secret.sync"]) + .resource_kinds(&["modelProfile", "authProfile"]), + RecipeActionCatalogEntry::new( + "delete_model_profile", + "Delete model profile", + "environment", + "models", + "orchestrated", + "Delete a model profile after checking for active bindings.", + ) + .recommended() + .capabilities(&["model.manage"]) + .resource_kinds(&["modelProfile", "authProfile"]), + RecipeActionCatalogEntry::new( + "ensure_provider_auth", + "Ensure provider auth", + "environment", + "models", + "orchestrated", + "Ensure a provider auth profile exists in the target environment.", + ) + .recommended() + .capabilities(&["auth.manage", "secret.sync"]) + .resource_kinds(&["authProfile"]), + RecipeActionCatalogEntry::new( + "delete_provider_auth", + "Delete provider auth", + "environment", + "models", + "orchestrated", + "Delete a provider auth profile after checking for dependent model bindings.", + ) + .recommended() + .capabilities(&["auth.manage"]) + .resource_kinds(&["authProfile"]), + RecipeActionCatalogEntry::new( + "setup_identity", + "Setup identity", + "legacy", + "agents", + "clawpal_fallback", + "Legacy compatibility action for identity and persona updates.", + ) + .alias_of("set_agent_identity") + .capabilities(&["agent.identity.write"]) + .resource_kinds(&["agent"]), + RecipeActionCatalogEntry::new( + "bind_channel", + "Bind channel", + "legacy", + "agents", + "openclaw_cli", + "Legacy compatibility action for channel binding based on peer/channel fields.", + ) + .alias_of("bind_agent") + .capabilities(&["binding.manage"]) + .resource_kinds(&["agent", "channel"]), + RecipeActionCatalogEntry::new( + "unbind_channel", + "Unbind channel", + "legacy", + "agents", + "openclaw_cli", + "Legacy compatibility action for channel unbinding based on peer/channel fields.", + ) + .alias_of("unbind_agent") + .capabilities(&["binding.manage"]) + .resource_kinds(&["channel"]), + RecipeActionCatalogEntry::new( + "config_patch", + "Config patch", + "legacy", + "config", + "openclaw_cli", + "Low-level escape hatch for direct config set operations.", + ) + .capabilities(&["config.write"]) + .resource_kinds(&["file"]), + RecipeActionCatalogEntry::new( + "list_agents", + "List agents", + "cli", + "agents", + "openclaw_cli", + "Run `openclaw agents list` as a read-only inspection action.", + ) + .cli("openclaw agents list") + .read_only(), + RecipeActionCatalogEntry::new( + "list_agent_bindings", + "List agent bindings", + "cli", + "agents", + "openclaw_cli", + "Run `openclaw agents bindings` as a read-only inspection action.", + ) + .cli("openclaw agents bindings") + .read_only(), + RecipeActionCatalogEntry::new( + "show_config_file", + "Show config file", + "cli", + "config", + "openclaw_cli", + "Print the active OpenClaw config file path.", + ) + .cli("openclaw config file") + .read_only(), + RecipeActionCatalogEntry::new( + "get_config_value", + "Get config value", + "cli", + "config", + "openclaw_cli", + "Read a config value through `openclaw config get`.", + ) + .cli("openclaw config get") + .read_only(), + RecipeActionCatalogEntry::new( + "set_config_value", + "Set config value", + "cli", + "config", + "openclaw_cli", + "Set a config value through `openclaw config set`.", + ) + .cli("openclaw config set") + .capabilities(&["config.write"]) + .resource_kinds(&["file"]), + RecipeActionCatalogEntry::new( + "unset_config_value", + "Unset config value", + "cli", + "config", + "openclaw_cli", + "Unset a config value through `openclaw config unset`.", + ) + .cli("openclaw config unset") + .capabilities(&["config.write"]) + .resource_kinds(&["file"]), + RecipeActionCatalogEntry::new( + "validate_config", + "Validate config", + "cli", + "config", + "openclaw_cli", + "Validate the active config without starting the gateway.", + ) + .cli("openclaw config validate") + .read_only(), + RecipeActionCatalogEntry::new( + "models_status", + "Models status", + "cli", + "models", + "openclaw_cli", + "Inspect resolved default models, fallbacks, and auth state.", + ) + .cli("openclaw models status") + .read_only(), + RecipeActionCatalogEntry::new( + "list_models", + "List models", + "cli", + "models", + "openclaw_cli", + "List known models through `openclaw models list`.", + ) + .cli("openclaw models list") + .read_only(), + RecipeActionCatalogEntry::new( + "set_default_model", + "Set default model", + "cli", + "models", + "openclaw_cli", + "Set the default OpenClaw model or alias.", + ) + .cli("openclaw models set") + .capabilities(&["model.manage"]) + .resource_kinds(&["modelProfile"]), + RecipeActionCatalogEntry::new( + "scan_models", + "Scan models", + "cli", + "models", + "openclaw_cli", + "Probe model/provider availability through `openclaw models scan`.", + ) + .cli("openclaw models scan") + .read_only(), + RecipeActionCatalogEntry::new( + "list_model_aliases", + "List model aliases", + "cli", + "models", + "openclaw_cli", + "List configured model aliases.", + ) + .cli("openclaw models aliases list") + .read_only(), + RecipeActionCatalogEntry::new( + "list_model_fallbacks", + "List model fallbacks", + "cli", + "models", + "openclaw_cli", + "List configured model fallbacks.", + ) + .cli("openclaw models fallbacks list") + .read_only(), + RecipeActionCatalogEntry::new( + "add_model_auth_profile", + "Add model auth profile", + "cli", + "models", + "openclaw_cli", + "Create a provider auth profile with provider-specific inputs.", + ) + .cli("openclaw models auth add") + .unsupported(), + RecipeActionCatalogEntry::new( + "login_model_auth", + "Login model auth", + "cli", + "models", + "openclaw_cli", + "Run a provider login flow for model auth.", + ) + .cli("openclaw models auth login") + .interactive(), + RecipeActionCatalogEntry::new( + "setup_model_auth_token", + "Setup model auth token", + "cli", + "models", + "openclaw_cli", + "Prompt for a setup token for provider auth.", + ) + .cli("openclaw models auth setup-token") + .interactive(), + RecipeActionCatalogEntry::new( + "paste_model_auth_token", + "Paste model auth token", + "cli", + "models", + "openclaw_cli", + "Paste a token for model auth. Not suitable for Recipe source because it carries secret material.", + ) + .cli("openclaw models auth paste-token") + .unsupported(), + RecipeActionCatalogEntry::new( + "list_channels", + "List channels", + "cli", + "channels", + "openclaw_cli", + "List configured channel accounts.", + ) + .cli("openclaw channels list") + .read_only(), + RecipeActionCatalogEntry::new( + "channels_status", + "Channels status", + "cli", + "channels", + "openclaw_cli", + "Inspect live channel health and config-only fallbacks.", + ) + .cli("openclaw channels status") + .read_only(), + RecipeActionCatalogEntry::new( + "read_channel_logs", + "Read channel logs", + "cli", + "channels", + "openclaw_cli", + "Read recent channel logs.", + ) + .cli("openclaw channels logs") + .read_only() + .unsupported(), + RecipeActionCatalogEntry::new( + "add_channel_account", + "Add channel account", + "cli", + "channels", + "openclaw_cli", + "Add a channel account with provider-specific flags.", + ) + .cli("openclaw channels add") + .unsupported(), + RecipeActionCatalogEntry::new( + "remove_channel_account", + "Remove channel account", + "cli", + "channels", + "openclaw_cli", + "Remove a configured channel account.", + ) + .cli("openclaw channels remove") + .unsupported(), + RecipeActionCatalogEntry::new( + "login_channel_account", + "Login channel account", + "cli", + "channels", + "openclaw_cli", + "Run an interactive login flow for a channel account.", + ) + .cli("openclaw channels login") + .interactive(), + RecipeActionCatalogEntry::new( + "logout_channel_account", + "Logout channel account", + "cli", + "channels", + "openclaw_cli", + "Run an interactive logout flow for a channel account.", + ) + .cli("openclaw channels logout") + .interactive(), + RecipeActionCatalogEntry::new( + "inspect_channel_capabilities", + "Inspect channel capabilities", + "cli", + "channels", + "openclaw_cli", + "Probe channel capabilities and target reachability.", + ) + .cli("openclaw channels capabilities") + .read_only(), + RecipeActionCatalogEntry::new( + "resolve_channel_targets", + "Resolve channel targets", + "cli", + "channels", + "openclaw_cli", + "Resolve names to channel/user ids through provider directories.", + ) + .cli("openclaw channels resolve") + .read_only(), + RecipeActionCatalogEntry::new( + "reload_secrets", + "Reload secrets", + "cli", + "secrets", + "openclaw_cli", + "Reload the active runtime secret snapshot.", + ) + .cli("openclaw secrets reload") + .read_only(), + RecipeActionCatalogEntry::new( + "audit_secrets", + "Audit secrets", + "cli", + "secrets", + "openclaw_cli", + "Audit unresolved SecretRefs and plaintext residues.", + ) + .cli("openclaw secrets audit") + .read_only(), + RecipeActionCatalogEntry::new( + "configure_secrets", + "Configure secrets", + "cli", + "secrets", + "openclaw_cli", + "Run the interactive SecretRef configuration helper.", + ) + .cli("openclaw secrets configure") + .interactive(), + RecipeActionCatalogEntry::new( + "apply_secrets_plan", + "Apply secrets plan", + "cli", + "secrets", + "openclaw_cli", + "Apply a saved secrets migration plan.", + ) + .cli("openclaw secrets apply") + .capabilities(&["auth.manage", "secret.sync"]) + .resource_kinds(&["authProfile", "file"]), + ] +} + +pub fn find_recipe_action(kind: &str) -> Option { + list_recipe_actions() + .into_iter() + .find(|entry| entry.kind == kind) +} diff --git a/src-tauri/src/recipe_adapter.rs b/src-tauri/src/recipe_adapter.rs new file mode 100644 index 00000000..2e47b644 --- /dev/null +++ b/src-tauri/src/recipe_adapter.rs @@ -0,0 +1,757 @@ +use serde::Serialize; +use serde_json::{json, Map, Value}; +use std::collections::BTreeSet; + +use crate::execution_spec::{ + validate_execution_spec, ExecutionAction, ExecutionCapabilities, ExecutionMetadata, + ExecutionResourceClaim, ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, +}; +use crate::recipe::{ + render_step_args, render_template_value, step_references_empty_param, validate, Recipe, + RecipeParam, RecipePresentation, RecipeStep, +}; +use crate::recipe_action_catalog::find_recipe_action as find_recipe_action_catalog_entry; +use crate::recipe_bundle::{ + validate_execution_spec_against_bundle, BundleCapabilities, BundleCompatibility, + BundleExecution, BundleMetadata, BundleResources, BundleRunner, RecipeBundle, +}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct RecipeSourceDocument { + pub id: String, + pub name: String, + pub description: String, + pub version: String, + pub tags: Vec, + pub difficulty: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub presentation: Option, + pub params: Vec, + pub steps: Vec, + #[serde(skip_serializing_if = "Option::is_none", rename = "clawpalPresetMaps")] + pub clawpal_preset_maps: Option>, + pub bundle: RecipeBundle, + pub execution_spec_template: ExecutionSpec, +} + +pub fn compile_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let errors = validate(recipe, params); + if !errors.is_empty() { + return Err(errors.join(", ")); + } + + if recipe.execution_spec_template.is_some() { + return compile_structured_recipe_to_spec(recipe, params); + } + + compile_step_recipe_to_spec(recipe, params) +} + +pub fn export_recipe_source(recipe: &Recipe) -> Result { + let execution_spec_template = build_recipe_spec_template(recipe)?; + let bundle = canonical_recipe_bundle(recipe, &execution_spec_template); + let document = RecipeSourceDocument { + id: recipe.id.clone(), + name: recipe.name.clone(), + description: recipe.description.clone(), + version: recipe.version.clone(), + tags: recipe.tags.clone(), + difficulty: recipe.difficulty.clone(), + presentation: recipe.presentation.clone(), + params: recipe.params.clone(), + steps: recipe.steps.clone(), + clawpal_preset_maps: recipe.clawpal_preset_maps.clone(), + bundle, + execution_spec_template, + }; + serde_json::to_string_pretty(&document).map_err(|error| error.to_string()) +} + +pub(crate) fn build_recipe_spec_template(recipe: &Recipe) -> Result { + if let Some(template) = &recipe.execution_spec_template { + return Ok(template.clone()); + } + build_step_recipe_template(recipe) +} + +fn compile_structured_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let template = recipe + .execution_spec_template + .as_ref() + .ok_or_else(|| format!("recipe '{}' is missing executionSpecTemplate", recipe.id))?; + let template_value = serde_json::to_value(template).map_err(|error| error.to_string())?; + let rendered_template = + render_template_value(&template_value, params, recipe.clawpal_preset_maps.as_ref()); + let mut spec: ExecutionSpec = + serde_json::from_value(rendered_template).map_err(|error| error.to_string())?; + + filter_optional_structured_actions(recipe, params, &mut spec)?; + validate_recipe_action_kinds(&spec.actions)?; + normalize_recipe_spec(recipe, Some(params), &mut spec, "structuredTemplate"); + + if let Some((used_capabilities, claims)) = infer_recipe_action_requirements(&spec.actions) { + spec.capabilities.used_capabilities = used_capabilities; + spec.resources.claims = claims; + } + + validate_recipe_spec(recipe, &spec)?; + Ok(spec) +} + +fn compile_step_recipe_to_spec( + recipe: &Recipe, + params: &Map, +) -> Result { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + let mut actions = Vec::new(); + + for step in &recipe.steps { + if step_references_empty_param(step, params) { + continue; + } + + let rendered_args = + render_step_args(&step.args, params, recipe.clawpal_preset_maps.as_ref()); + collect_action_requirements( + step.action.as_str(), + &rendered_args, + &mut used_capabilities, + &mut claims, + ); + actions.push(build_recipe_action(step, rendered_args)?); + } + + let execution_kind = if actions + .iter() + .all(|action| action.kind.as_deref() == Some("config_patch")) + { + "attachment" + } else { + "job" + }; + + let mut spec = ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some(recipe.id.clone()), + digest: None, + }, + source: Value::Object(Map::new()), + target: Value::Object(Map::new()), + execution: ExecutionTarget { + kind: execution_kind.into(), + }, + capabilities: ExecutionCapabilities { used_capabilities }, + resources: ExecutionResources { claims }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": actions.len(), + }), + actions, + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })], + }; + + normalize_recipe_spec(recipe, Some(params), &mut spec, "stepAdapter"); + validate_recipe_spec(recipe, &spec)?; + Ok(spec) +} + +fn build_step_recipe_template(recipe: &Recipe) -> Result { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + let mut actions = Vec::new(); + + for step in &recipe.steps { + collect_action_requirements( + step.action.as_str(), + &step.args, + &mut used_capabilities, + &mut claims, + ); + actions.push(build_recipe_action(step, step.args.clone())?); + } + + let execution_kind = if actions + .iter() + .all(|action| action.kind.as_deref() == Some("config_patch")) + { + "attachment" + } else { + "job" + }; + + let mut spec = ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some(recipe.id.clone()), + digest: None, + }, + source: Value::Object(Map::new()), + target: Value::Object(Map::new()), + execution: ExecutionTarget { + kind: execution_kind.into(), + }, + capabilities: ExecutionCapabilities { used_capabilities }, + resources: ExecutionResources { claims }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": actions.len(), + }), + actions, + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })], + }; + + normalize_recipe_spec(recipe, None, &mut spec, "stepTemplate"); + Ok(spec) +} + +fn build_recipe_presentation_source( + recipe: &Recipe, + params: Option<&Map>, +) -> Option { + let presentation = recipe.presentation.as_ref()?; + let raw_value = serde_json::to_value(presentation).ok()?; + Some(match params { + Some(params) => { + render_template_value(&raw_value, params, recipe.clawpal_preset_maps.as_ref()) + } + None => raw_value, + }) +} + +fn normalize_recipe_spec( + recipe: &Recipe, + params: Option<&Map>, + spec: &mut ExecutionSpec, + compiler: &str, +) { + if spec.metadata.name.is_none() { + spec.metadata.name = Some(recipe.id.clone()); + } + + let mut source = spec.source.as_object().cloned().unwrap_or_default(); + source.insert("recipeId".into(), Value::String(recipe.id.clone())); + source.insert( + "recipeVersion".into(), + Value::String(recipe.version.clone()), + ); + source.insert("recipeCompiler".into(), Value::String(compiler.into())); + if let Some(presentation) = build_recipe_presentation_source(recipe, params) { + source.insert("recipePresentation".into(), presentation); + } + spec.source = Value::Object(source); + + if let Some(desired_state) = spec.desired_state.as_object_mut() { + desired_state.insert("actionCount".into(), json!(spec.actions.len())); + } else { + spec.desired_state = json!({ + "actionCount": spec.actions.len(), + }); + } + + if spec.outputs.is_empty() { + spec.outputs.push(json!({ + "kind": "recipe-summary", + "recipeId": recipe.id, + })); + } +} + +fn validate_recipe_spec(recipe: &Recipe, spec: &ExecutionSpec) -> Result<(), String> { + if let Some(bundle) = &recipe.bundle { + validate_execution_spec_against_bundle(bundle, spec) + } else { + validate_execution_spec(spec) + } +} + +pub(crate) fn canonical_recipe_bundle(recipe: &Recipe, spec: &ExecutionSpec) -> RecipeBundle { + if let Some(bundle) = &recipe.bundle { + return bundle.clone(); + } + + let allowed_capabilities = spec + .capabilities + .used_capabilities + .iter() + .cloned() + .collect::>() + .into_iter() + .collect(); + let supported_resource_kinds = spec + .resources + .claims + .iter() + .map(|claim| claim.kind.clone()) + .collect::>() + .into_iter() + .collect(); + + RecipeBundle { + api_version: "strategy.platform/v1".into(), + kind: "StrategyBundle".into(), + metadata: BundleMetadata { + name: Some(recipe.id.clone()), + version: Some(recipe.version.clone()), + description: Some(recipe.description.clone()), + }, + compatibility: BundleCompatibility::default(), + inputs: Vec::new(), + capabilities: BundleCapabilities { + allowed: allowed_capabilities, + }, + resources: BundleResources { + supported_kinds: supported_resource_kinds, + }, + execution: BundleExecution { + supported_kinds: vec![spec.execution.kind.clone()], + }, + runner: BundleRunner::default(), + outputs: spec.outputs.clone(), + } +} + +fn filter_optional_structured_actions( + recipe: &Recipe, + params: &Map, + spec: &mut ExecutionSpec, +) -> Result<(), String> { + let skipped_step_indices: BTreeSet = recipe + .steps + .iter() + .enumerate() + .filter(|(_, step)| step_references_empty_param(step, params)) + .map(|(index, _)| index) + .collect(); + if skipped_step_indices.is_empty() { + return Ok(()); + } + + if spec.actions.len() != recipe.steps.len() { + return Err(format!( + "recipe '{}' executionSpecTemplate must align actions with UI steps for optional step elision", + recipe.id + )); + } + + spec.actions = spec + .actions + .iter() + .enumerate() + .filter_map(|(index, action)| { + if skipped_step_indices.contains(&index) { + None + } else { + Some(action.clone()) + } + }) + .collect(); + Ok(()) +} + +fn infer_recipe_action_requirements( + actions: &[ExecutionAction], +) -> Option<(Vec, Vec)> { + let mut used_capabilities = Vec::new(); + let mut claims = Vec::new(); + + for action in actions { + let kind = action.kind.as_deref()?; + let args = action.args.as_object()?; + let entry = find_recipe_action_catalog_entry(kind)?; + if !entry.runner_supported { + return None; + } + + collect_action_requirements(kind, args, &mut used_capabilities, &mut claims); + } + + Some((used_capabilities, claims)) +} + +fn build_recipe_action( + step: &RecipeStep, + mut rendered_args: Map, +) -> Result { + let action_entry = find_recipe_action_catalog_entry(step.action.as_str()) + .ok_or_else(|| format!("recipe action '{}' is not recognized", step.action))?; + if !action_entry.runner_supported { + return Err(format!( + "recipe action '{}' is documented but not supported by the Recipe runner", + step.action + )); + } + + let args = if step.action == "config_patch" { + let mut action_args = Map::new(); + if let Some(Value::String(patch_template)) = rendered_args.remove("patchTemplate") { + let patch: Value = + json5::from_str(&patch_template).map_err(|error| error.to_string())?; + action_args.insert("patchTemplate".into(), Value::String(patch_template)); + action_args.insert("patch".into(), patch); + } + action_args.extend(rendered_args); + Value::Object(action_args) + } else { + Value::Object(rendered_args) + }; + + Ok(ExecutionAction { + kind: Some(step.action.clone()), + name: Some(step.label.clone()), + args, + }) +} + +fn validate_recipe_action_kinds(actions: &[ExecutionAction]) -> Result<(), String> { + for action in actions { + let kind = action + .kind + .as_deref() + .ok_or_else(|| "recipe action is missing kind".to_string())?; + let entry = find_recipe_action_catalog_entry(kind) + .ok_or_else(|| format!("recipe action '{}' is not recognized", kind))?; + if !entry.runner_supported { + return Err(format!( + "recipe action '{}' is documented but not supported by the Recipe runner", + kind + )); + } + } + Ok(()) +} + +fn collect_action_requirements( + action_kind: &str, + rendered_args: &Map, + used_capabilities: &mut Vec, + claims: &mut Vec, +) { + match action_kind { + "create_agent" => { + push_capability(used_capabilities, "agent.manage"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "delete_agent" => { + push_capability(used_capabilities, "agent.manage"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "setup_identity" => { + push_capability(used_capabilities, "agent.identity.write"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "set_agent_identity" => { + push_capability(used_capabilities, "agent.identity.write"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "set_agent_persona" | "clear_agent_persona" => { + push_capability(used_capabilities, "agent.identity.write"); + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + } + "bind_agent" => { + push_capability(used_capabilities, "binding.manage"); + let channel_id = rendered_args + .get("binding") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let agent_id = rendered_args + .get("agentId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: agent_id, + path: None, + }, + ); + } + "unbind_agent" => { + push_capability(used_capabilities, "binding.manage"); + let channel_id = rendered_args + .get("binding") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: None, + path: None, + }, + ); + } + "bind_channel" => { + push_capability(used_capabilities, "binding.manage"); + let channel_id = rendered_args + .get("peerId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let agent_id = rendered_args + .get("agentId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: agent_id, + path: None, + }, + ); + } + "unbind_channel" => { + push_capability(used_capabilities, "binding.manage"); + let channel_id = rendered_args + .get("peerId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: None, + path: None, + }, + ); + } + "set_agent_model" => { + push_capability(used_capabilities, "model.manage"); + if rendered_args + .get("ensureProfile") + .and_then(Value::as_bool) + .unwrap_or(true) + { + push_capability(used_capabilities, "secret.sync"); + } + push_optional_id_claim(claims, "agent", rendered_args.get("agentId")); + push_optional_id_claim(claims, "modelProfile", rendered_args.get("profileId")); + } + "set_channel_persona" | "clear_channel_persona" => { + push_capability(used_capabilities, "config.write"); + let channel_id = rendered_args + .get("peerId") + .and_then(Value::as_str) + .map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: "channel".into(), + id: channel_id, + target: None, + path: None, + }, + ); + } + "config_patch" => { + push_capability(used_capabilities, "config.write"); + push_claim( + claims, + ExecutionResourceClaim { + kind: "file".into(), + id: Some("openclaw.config".into()), + target: None, + path: Some("openclaw.config".into()), + }, + ); + } + "set_config_value" | "unset_config_value" => { + push_capability(used_capabilities, "config.write"); + push_claim( + claims, + ExecutionResourceClaim { + kind: "file".into(), + id: action_string(rendered_args.get("path")), + target: None, + path: action_string(rendered_args.get("path")), + }, + ); + } + "set_default_model" => { + push_capability(used_capabilities, "model.manage"); + push_optional_id_claim(claims, "modelProfile", rendered_args.get("modelOrAlias")); + } + "upsert_markdown_document" => { + push_capability(used_capabilities, "document.write"); + if let Some(path) = document_target_claim_path(rendered_args) { + push_claim( + claims, + ExecutionResourceClaim { + kind: "document".into(), + id: None, + target: None, + path: Some(path), + }, + ); + } + } + "delete_markdown_document" => { + push_capability(used_capabilities, "document.delete"); + if let Some(path) = document_target_claim_path(rendered_args) { + push_claim( + claims, + ExecutionResourceClaim { + kind: "document".into(), + id: None, + target: None, + path: Some(path), + }, + ); + } + } + "ensure_model_profile" => { + push_capability(used_capabilities, "model.manage"); + push_capability(used_capabilities, "secret.sync"); + push_optional_id_claim(claims, "modelProfile", rendered_args.get("profileId")); + } + "delete_model_profile" => { + push_capability(used_capabilities, "model.manage"); + push_optional_id_claim(claims, "modelProfile", rendered_args.get("profileId")); + if action_bool(rendered_args.get("deleteAuthRef")) { + if let Some(auth_ref) = action_string(rendered_args.get("authRef")) { + push_claim( + claims, + ExecutionResourceClaim { + kind: "authProfile".into(), + id: Some(auth_ref), + target: None, + path: None, + }, + ); + } + } + } + "ensure_provider_auth" => { + push_capability(used_capabilities, "auth.manage"); + push_capability(used_capabilities, "secret.sync"); + let auth_ref = action_string(rendered_args.get("authRef")).or_else(|| { + action_string(rendered_args.get("provider")) + .map(|provider| format!("{}:default", provider.trim().to_ascii_lowercase())) + }); + push_claim( + claims, + ExecutionResourceClaim { + kind: "authProfile".into(), + id: auth_ref, + target: None, + path: None, + }, + ); + } + "delete_provider_auth" => { + push_capability(used_capabilities, "auth.manage"); + push_optional_id_claim(claims, "authProfile", rendered_args.get("authRef")); + } + "apply_secrets_plan" => { + push_capability(used_capabilities, "auth.manage"); + push_capability(used_capabilities, "secret.sync"); + push_claim( + claims, + ExecutionResourceClaim { + kind: "file".into(), + id: action_string(rendered_args.get("fromPath")), + target: None, + path: action_string(rendered_args.get("fromPath")), + }, + ); + } + _ => {} + } +} + +fn document_target_claim_path(rendered_args: &Map) -> Option { + let target = rendered_args.get("target")?.as_object()?; + let scope = target.get("scope").and_then(Value::as_str)?.trim(); + let path = target.get("path").and_then(Value::as_str)?.trim(); + if scope.is_empty() || path.is_empty() { + return None; + } + + if scope == "agent" { + let agent_id = target.get("agentId").and_then(Value::as_str)?.trim(); + if agent_id.is_empty() { + return None; + } + return Some(format!("agent:{agent_id}/{path}")); + } + + Some(format!("{scope}:{path}")) +} + +fn push_capability(target: &mut Vec, capability: &str) { + if !target.iter().any(|item| item == capability) { + target.push(capability.into()); + } +} + +fn action_string(value: Option<&Value>) -> Option { + value.and_then(|value| match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + _ => None, + }) +} + +fn action_bool(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(value)) => *value, + Some(Value::String(value)) => value.trim().eq_ignore_ascii_case("true"), + _ => false, + } +} + +fn push_optional_id_claim( + claims: &mut Vec, + kind: &str, + id: Option<&Value>, +) { + let id = id.and_then(Value::as_str).map(|value| value.to_string()); + push_claim( + claims, + ExecutionResourceClaim { + kind: kind.into(), + id, + target: None, + path: None, + }, + ); +} + +fn push_claim(claims: &mut Vec, next: ExecutionResourceClaim) { + let exists = claims.iter().any(|claim| { + claim.kind == next.kind + && claim.id == next.id + && claim.target == next.target + && claim.path == next.path + }); + if !exists { + claims.push(next); + } +} diff --git a/src-tauri/src/recipe_adapter_tests.rs b/src-tauri/src/recipe_adapter_tests.rs new file mode 100644 index 00000000..8bf4c101 --- /dev/null +++ b/src-tauri/src/recipe_adapter_tests.rs @@ -0,0 +1,1100 @@ +use serde_json::{Map, Value}; + +use crate::recipe::{ + load_recipes_from_source_text, validate_recipe_source, Recipe, RecipeParam, RecipePresentation, + RecipeStep, +}; +use crate::recipe_adapter::{compile_recipe_to_spec, export_recipe_source}; + +const TEST_RECIPES_SOURCE: &str = r#"{ + "recipes": [ + { + "id": "dedicated-channel-agent", + "name": "Create dedicated Agent for Channel", + "description": "Create an agent and bind it to a Discord channel", + "version": "1.0.0", + "tags": ["discord", "agent", "persona"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent ID", "type": "string", "required": true, "placeholder": "e.g. my-bot" }, + { "id": "model", "label": "Model", "type": "model_profile", "required": true, "defaultValue": "__default__" }, + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "independent", "label": "Create independent agent", "type": "boolean", "required": false }, + { "id": "name", "label": "Display Name", "type": "string", "required": false, "dependsOn": "independent" }, + { "id": "emoji", "label": "Emoji", "type": "string", "required": false, "dependsOn": "independent" }, + { "id": "persona", "label": "Persona", "type": "textarea", "required": false, "dependsOn": "independent" } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "dedicated-channel-agent", + "version": "1.0.0", + "description": "Create an agent and bind it to a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.manage", "agent.identity.write", "binding.manage", "config.write"] + }, + "resources": { + "supportedKinds": ["agent", "channel", "file"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "dedicated-channel-agent" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 4 + }, + "actions": [ + { + "kind": "create_agent", + "name": "Create agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}", + "independent": "{{independent}}" + } + }, + { + "kind": "setup_identity", + "name": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "kind": "bind_channel", + "name": "Bind channel to agent", + "args": { + "channelType": "discord", + "peerId": "{{channel_id}}", + "agentId": "{{agent_id}}" + } + }, + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, + "steps": [ + { "action": "create_agent", "label": "Create agent", "args": { "agentId": "{{agent_id}}", "modelProfileId": "{{model}}", "independent": "{{independent}}" } }, + { "action": "setup_identity", "label": "Set agent identity", "args": { "agentId": "{{agent_id}}", "name": "{{name}}", "emoji": "{{emoji}}" } }, + { "action": "bind_channel", "label": "Bind channel to agent", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "agentId": "{{agent_id}}" } }, + { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } + ] + }, + { + "id": "discord-channel-persona", + "name": "Channel Persona", + "description": "Set a custom persona for a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona", "beginner"], + "difficulty": "easy", + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "persona", "label": "Persona", "type": "textarea", "required": true, "placeholder": "You are..." } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "discord-channel-persona", + "version": "1.0.0", + "description": "Set a custom persona for a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["config.write"] + }, + "resources": { + "supportedKinds": ["file"] + }, + "execution": { + "supportedKinds": ["attachment"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "discord-channel-persona" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "attachment" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 1 + }, + "actions": [ + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, + "steps": [ + { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } + ] + } + ] +}"#; + +fn test_recipe(id: &str) -> Recipe { + load_recipes_from_source_text(TEST_RECIPES_SOURCE) + .expect("parse test recipe source") + .into_iter() + .find(|recipe| recipe.id == id) + .expect("test recipe") +} + +fn sample_params() -> Map { + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("bot-alpha".into())); + params.insert("model".into(), Value::String("__default__".into())); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert("independent".into(), Value::String("true".into())); + params.insert("name".into(), Value::String("Bot Alpha".into())); + params.insert("emoji".into(), Value::String(":claw:".into())); + params.insert( + "persona".into(), + Value::String("You are a focused channel assistant.".into()), + ); + params +} + +#[test] +fn recipe_compiles_to_attachment_or_job_spec() { + let recipe = test_recipe("dedicated-channel-agent"); + + let spec = compile_recipe_to_spec(&recipe, &sample_params()).expect("compile spec"); + + assert!(matches!(spec.execution.kind.as_str(), "attachment" | "job")); + assert!(!spec.actions.is_empty()); + assert_eq!( + spec.source.get("recipeId").and_then(Value::as_str), + Some(recipe.id.as_str()) + ); + assert_eq!( + spec.source.get("recipeCompiler").and_then(Value::as_str), + Some("structuredTemplate") + ); + assert!(spec.source.get("legacyRecipeId").is_none()); +} + +#[test] +fn config_patch_only_recipe_compiles_to_attachment_spec() { + let recipe = test_recipe("discord-channel-persona"); + + let spec = compile_recipe_to_spec(&recipe, &sample_params()).expect("compile spec"); + + assert_eq!(spec.execution.kind, "attachment"); + assert_eq!(spec.actions.len(), 1); + assert_eq!( + spec.outputs[0].get("kind").and_then(Value::as_str), + Some("recipe-summary") + ); + let patch = spec.actions[0] + .args + .get("patch") + .and_then(Value::as_object) + .expect("rendered patch"); + assert!(patch.get("channels").is_some()); + let rendered_patch = serde_json::to_string(&spec.actions[0].args).expect("patch json"); + assert!(rendered_patch.contains("\"guild-1\"")); + assert!(rendered_patch.contains("\"channel-1\"")); + assert!(!rendered_patch.contains("{{guild_id}}")); +} + +#[test] +fn structured_recipe_template_skips_optional_actions_with_empty_params() { + let recipe = test_recipe("dedicated-channel-agent"); + let mut params = sample_params(); + params.insert("name".into(), Value::String(String::new())); + params.insert("emoji".into(), Value::String(String::new())); + params.insert("persona".into(), Value::String(String::new())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!(spec.actions.len(), 2); + assert_eq!(spec.actions[0].kind.as_deref(), Some("create_agent")); + assert_eq!(spec.actions[1].kind.as_deref(), Some("bind_channel")); +} + +#[test] +fn export_recipe_source_normalizes_step_only_recipe_to_structured_document() { + let recipe = Recipe { + id: "legacy-channel-persona".into(), + name: "Legacy Channel Persona".into(), + description: "Set channel persona with steps only".into(), + version: "1.0.0".into(), + tags: vec!["discord".into(), "persona".into()], + difficulty: "easy".into(), + presentation: Some(RecipePresentation { + result_summary: Some("Updated persona for {{channel_id}}".into()), + }), + params: vec![ + RecipeParam { + id: "guild_id".into(), + label: "Guild".into(), + kind: "discord_guild".into(), + required: true, + pattern: None, + min_length: None, + max_length: None, + placeholder: None, + depends_on: None, + default_value: None, + options: None, + }, + RecipeParam { + id: "channel_id".into(), + label: "Channel".into(), + kind: "discord_channel".into(), + required: true, + pattern: None, + min_length: None, + max_length: None, + placeholder: None, + depends_on: None, + default_value: None, + options: None, + }, + ], + steps: vec![RecipeStep { + action: "config_patch".into(), + label: "Set channel persona".into(), + args: serde_json::from_value(serde_json::json!({ + "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"hello\"}}}}}}}" + })) + .expect("step args"), + }], + clawpal_preset_maps: None, + bundle: None, + execution_spec_template: None, + }; + + let exported = export_recipe_source(&recipe).expect("export source"); + + assert!(exported.contains("\"bundle\"")); + assert!(exported.contains("\"executionSpecTemplate\"")); + assert!(exported.contains("\"presentation\"")); + assert!(exported.contains("Updated persona for {{channel_id}}")); + assert!(exported.contains("\"supportedKinds\": [\n \"attachment\"")); + assert!(exported.contains("\"{{guild_id}}\"")); +} + +#[test] +fn structured_recipe_compilation_renders_result_summary_into_spec_source() { + let recipe = Recipe { + id: "persona-pack".into(), + name: "Persona Pack".into(), + description: "Apply a persona pack".into(), + version: "1.0.0".into(), + tags: vec!["agent".into(), "persona".into()], + difficulty: "easy".into(), + presentation: Some(RecipePresentation { + result_summary: Some("Updated persona for {{agent_id}}".into()), + }), + params: vec![RecipeParam { + id: "agent_id".into(), + label: "Agent".into(), + kind: "agent".into(), + required: true, + pattern: None, + min_length: None, + max_length: None, + placeholder: None, + depends_on: None, + default_value: None, + options: None, + }], + steps: vec![RecipeStep { + action: "setup_identity".into(), + label: "Apply persona".into(), + args: serde_json::from_value(serde_json::json!({ + "agentId": "{{agent_id}}", + "persona": "You are calm and direct." + })) + .expect("step args"), + }], + clawpal_preset_maps: None, + bundle: None, + execution_spec_template: Some( + serde_json::from_value(serde_json::json!({ + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": ["agent.identity.write"] }, + "resources": { "claims": [{ "kind": "agent", "id": "{{agent_id}}" }] }, + "secrets": { "bindings": [] }, + "desiredState": { "actionCount": 1 }, + "actions": [ + { + "kind": "setup_identity", + "name": "Apply persona", + "args": { + "agentId": "{{agent_id}}", + "persona": "You are calm and direct." + } + } + ], + "outputs": [] + })) + .expect("template"), + ), + }; + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("main".into())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!( + spec.source + .get("recipePresentation") + .and_then(|value| value.get("resultSummary")) + .and_then(Value::as_str), + Some("Updated persona for main") + ); +} + +#[test] +fn exported_recipe_source_validates_as_structured_document() { + let recipe = test_recipe("discord-channel-persona"); + let source = export_recipe_source(&recipe).expect("export source"); + + let diagnostics = validate_recipe_source(&source).expect("validate source"); + + assert!(diagnostics.errors.is_empty()); +} + +#[test] +fn validate_recipe_source_flags_parse_errors() { + let diagnostics = validate_recipe_source("{ broken").expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "parse"); +} + +#[test] +fn validate_recipe_source_flags_bundle_consistency_errors() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "bundle-mismatch", + "name": "Bundle Mismatch", + "description": "Invalid bundle/spec pairing", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "bundle"); +} + +#[test] +fn validate_recipe_source_flags_step_alignment_errors() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "step-mismatch", + "name": "Step Mismatch", + "description": "Invalid step/action alignment", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [ + { "action": "config_patch", "label": "First", "args": {} }, + { "action": "config_patch", "label": "Second", "args": {} } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "config_patch", "name": "Only action", "args": {} } + ], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "alignment"); +} + +#[test] +fn structured_recipe_template_resolves_preset_map_placeholders_from_compiled_source() { + let recipe = crate::recipe::load_recipes_from_source_text( + r#"{ + "id": "channel-persona-pack", + "name": "Channel Persona Pack", + "description": "Apply a preset persona to a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona"], + "difficulty": "easy", + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { + "id": "persona_preset", + "label": "Persona preset", + "type": "string", + "required": true, + "options": [ + { "value": "ops", "label": "Ops" } + ] + } + ], + "steps": [ + { + "action": "config_patch", + "label": "Apply persona preset", + "args": { + "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{presetMap:persona_preset}}\"}}}}}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["config.write"] }, + "resources": { "supportedKinds": ["file"] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "channel-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": ["config.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "config_patch", + "name": "Apply persona preset", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{presetMap:persona_preset}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [] + }, + "clawpalPresetMaps": { + "persona_preset": { + "ops": "You are an on-call operations coordinator." + } + } + }"#, + ) + .expect("load source") + .into_iter() + .next() + .expect("recipe"); + + let mut params = Map::new(); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-2".into())); + params.insert("persona_preset".into(), Value::String("ops".into())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!( + spec.actions[0] + .args + .pointer("/patch/channels/discord/guilds/guild-1/channels/channel-2/systemPrompt") + .and_then(Value::as_str), + Some("You are an on-call operations coordinator.") + ); +} + +#[test] +fn validate_recipe_source_flags_hidden_actions_without_ui_steps() { + let diagnostics = validate_recipe_source( + r#"{ + "recipes": [{ + "id": "hidden-actions", + "name": "Hidden Actions", + "description": "Execution actions without UI steps", + "version": "1.0.0", + "tags": [], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "config_patch", "name": "Only action", "args": {} } + ], + "outputs": [] + } + }] + }"#, + ) + .expect("validate source"); + + assert_eq!(diagnostics.errors.len(), 1); + assert_eq!(diagnostics.errors[0].category, "alignment"); +} + +#[test] +fn structured_recipe_template_resolves_agent_persona_preset_text() { + let recipe = load_recipes_from_source_text( + r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Import persona presets into an existing agent", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent", "type": "agent", "required": true }, + { + "id": "persona_preset", + "label": "Persona preset", + "type": "string", + "required": true, + "options": [{ "value": "friendly", "label": "Friendly" }] + } + ], + "steps": [ + { + "action": "setup_identity", + "label": "Apply preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["agent.identity.write"] }, + "resources": { "supportedKinds": ["agent"] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "agent-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": ["agent.identity.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "setup_identity", + "name": "Apply preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "outputs": [] + }, + "clawpalPresetMaps": { + "persona_preset": { + "friendly": "You are warm, concise, and practical." + } + } + }"#, + ) + .expect("load recipe") + .into_iter() + .next() + .expect("recipe"); + + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("lobster".into())); + params.insert("persona_preset".into(), Value::String("friendly".into())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!( + spec.actions[0].args.get("persona").and_then(Value::as_str), + Some("You are warm, concise, and practical.") + ); +} + +#[test] +fn structured_recipe_template_resolves_channel_persona_preset_into_patch() { + let recipe = load_recipes_from_source_text( + r#"{ + "id": "channel-persona-pack", + "name": "Channel Persona Pack", + "description": "Import persona presets into a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona"], + "difficulty": "easy", + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { + "id": "persona_preset", + "label": "Persona preset", + "type": "string", + "required": true, + "options": [{ "value": "ops", "label": "Ops" }] + } + ], + "steps": [ + { + "action": "config_patch", + "label": "Apply preset", + "args": {} + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["config.write"] }, + "resources": { "supportedKinds": ["file"] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "channel-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": ["config.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "config_patch", + "name": "Apply preset", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{presetMap:persona_preset}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [] + }, + "clawpalPresetMaps": { + "persona_preset": { + "ops": "You are a crisp channel ops assistant." + } + } + }"#, + ) + .expect("load recipe") + .into_iter() + .next() + .expect("recipe"); + + let mut params = Map::new(); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert("persona_preset".into(), Value::String("ops".into())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert_eq!( + spec.actions[0] + .args + .pointer("/patch/channels/discord/guilds/guild-1/channels/channel-1/systemPrompt") + .and_then(Value::as_str), + Some("You are a crisp channel ops assistant.") + ); +} + +#[test] +fn structured_recipe_compilation_infers_capabilities_and_claims_for_new_actions() { + let recipe = load_recipes_from_source_text( + r##"{ + "id": "runner-action-suite", + "name": "Runner Action Suite", + "description": "Exercise the extended action surface", + "version": "1.0.0", + "tags": ["runner"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent", "type": "agent", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "profile_id", "label": "Model profile", "type": "model_profile", "required": true } + ], + "steps": [ + { + "action": "ensure_model_profile", + "label": "Prepare model access", + "args": { "profileId": "{{profile_id}}" } + }, + { + "action": "set_agent_persona", + "label": "Set agent persona", + "args": { "agentId": "{{agent_id}}", "persona": "You are direct." } + }, + { + "action": "set_channel_persona", + "label": "Set channel persona", + "args": { "channelType": "discord", "peerId": "{{channel_id}}", "persona": "Stay crisp." } + }, + { + "action": "upsert_markdown_document", + "label": "Write agent notes", + "args": { + "target": { "scope": "agent", "agentId": "{{agent_id}}", "path": "PLAYBOOK.md" }, + "mode": "replace", + "content": "# Playbook\n" + } + }, + { + "action": "ensure_provider_auth", + "label": "Ensure provider auth", + "args": { "provider": "openai", "authRef": "openai:default" } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": [ + "model.manage", + "agent.identity.write", + "config.write", + "document.write", + "auth.manage", + "secret.sync" + ] + }, + "resources": { + "supportedKinds": ["agent", "channel", "document", "modelProfile", "authProfile"] + }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "runner-action-suite" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "ensure_model_profile", "name": "Prepare model access", "args": { "profileId": "{{profile_id}}" } }, + { "kind": "set_agent_persona", "name": "Set agent persona", "args": { "agentId": "{{agent_id}}", "persona": "You are direct." } }, + { "kind": "set_channel_persona", "name": "Set channel persona", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "persona": "Stay crisp." } }, + { + "kind": "upsert_markdown_document", + "name": "Write agent notes", + "args": { + "target": { "scope": "agent", "agentId": "{{agent_id}}", "path": "PLAYBOOK.md" }, + "mode": "replace", + "content": "# Playbook\n" + } + }, + { "kind": "ensure_provider_auth", "name": "Ensure provider auth", "args": { "provider": "openai", "authRef": "openai:default" } } + ], + "outputs": [] + } + }"##, + ) + .expect("load recipe") + .into_iter() + .next() + .expect("recipe"); + + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("main".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert("profile_id".into(), Value::String("remote-openai".into())); + + let spec = compile_recipe_to_spec(&recipe, ¶ms).expect("compile spec"); + + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "model.manage")); + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "agent.identity.write")); + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "config.write")); + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "document.write")); + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "auth.manage")); + assert!(spec + .capabilities + .used_capabilities + .iter() + .any(|value| value == "secret.sync")); + + assert!(spec + .resources + .claims + .iter() + .any(|claim| { claim.kind == "agent" && claim.id.as_deref() == Some("main") })); + assert!(spec + .resources + .claims + .iter() + .any(|claim| { claim.kind == "channel" && claim.id.as_deref() == Some("channel-1") })); + assert!(spec.resources.claims.iter().any(|claim| { + claim.kind == "document" && claim.path.as_deref() == Some("agent:main/PLAYBOOK.md") + })); + assert!(spec.resources.claims.iter().any(|claim| { + claim.kind == "modelProfile" && claim.id.as_deref() == Some("remote-openai") + })); + assert!(spec.resources.claims.iter().any(|claim| { + claim.kind == "authProfile" && claim.id.as_deref() == Some("openai:default") + })); +} + +#[test] +fn compile_recipe_rejects_documented_but_unsupported_actions() { + let recipe = load_recipes_from_source_text( + r##"{ + "id": "interactive-auth", + "name": "Interactive auth", + "description": "Should fail in compile", + "version": "1.0.0", + "tags": ["models"], + "difficulty": "advanced", + "params": [], + "steps": [ + { "action": "login_model_auth", "label": "Login", "args": { "provider": "openai" } } + ] + }"##, + ) + .expect("load recipe") + .into_iter() + .next() + .expect("recipe"); + + let error = compile_recipe_to_spec(&recipe, &Map::new()).expect_err("compile should fail"); + + assert!(error.contains("not supported by the Recipe runner")); +} diff --git a/src-tauri/src/recipe_bundle.rs b/src-tauri/src/recipe_bundle.rs new file mode 100644 index 00000000..6dbfeb42 --- /dev/null +++ b/src-tauri/src/recipe_bundle.rs @@ -0,0 +1,103 @@ +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const SUPPORTED_EXECUTION_KINDS: &[&str] = &["job", "service", "schedule", "attachment"]; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleMetadata { + pub name: Option, + pub version: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleCompatibility { + pub min_runner_version: Option, + pub target_platforms: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleCapabilities { + pub allowed: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleResources { + pub supported_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleExecution { + pub supported_kinds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct BundleRunner { + pub name: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct RecipeBundle { + #[serde(rename = "apiVersion")] + pub api_version: String, + pub kind: String, + pub metadata: BundleMetadata, + pub compatibility: BundleCompatibility, + pub inputs: Vec, + pub capabilities: BundleCapabilities, + pub resources: BundleResources, + pub execution: BundleExecution, + pub runner: BundleRunner, + pub outputs: Vec, +} + +pub fn parse_recipe_bundle(raw: &str) -> Result { + let bundle: RecipeBundle = parse_structured_document(raw)?; + validate_recipe_bundle(&bundle)?; + Ok(bundle) +} + +pub fn validate_recipe_bundle(bundle: &RecipeBundle) -> Result<(), String> { + if bundle.kind != "StrategyBundle" { + return Err(format!("unsupported document kind: {}", bundle.kind)); + } + + for kind in &bundle.execution.supported_kinds { + validate_execution_kind(kind)?; + } + Ok(()) +} + +pub fn validate_execution_spec_against_bundle( + bundle: &RecipeBundle, + spec: &crate::execution_spec::ExecutionSpec, +) -> Result<(), String> { + crate::execution_spec::validate_execution_spec_against_bundle(spec, bundle) +} + +pub(crate) fn parse_structured_document(raw: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(raw) + .or_else(|_| json5::from_str(raw)) + .or_else(|_| serde_yaml::from_str(raw)) + .map_err(|error| format!("failed to parse structured document: {error}")) +} + +pub(crate) fn validate_execution_kind(kind: &str) -> Result<(), String> { + if SUPPORTED_EXECUTION_KINDS.contains(&kind) { + Ok(()) + } else { + Err(format!("unsupported execution kind: {kind}")) + } +} diff --git a/src-tauri/src/recipe_bundle_tests.rs b/src-tauri/src/recipe_bundle_tests.rs new file mode 100644 index 00000000..bd472fa9 --- /dev/null +++ b/src-tauri/src/recipe_bundle_tests.rs @@ -0,0 +1,10 @@ +use crate::recipe_bundle::parse_recipe_bundle; + +#[test] +fn recipe_bundle_rejects_unknown_execution_kind() { + let raw = r#"apiVersion: strategy.platform/v1 +kind: StrategyBundle +execution: { supportedKinds: [workflow] }"#; + + assert!(parse_recipe_bundle(raw).is_err()); +} diff --git a/src-tauri/src/recipe_executor.rs b/src-tauri/src/recipe_executor.rs new file mode 100644 index 00000000..2ca1b374 --- /dev/null +++ b/src-tauri/src/recipe_executor.rs @@ -0,0 +1,433 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::execution_spec::ExecutionSpec; +use crate::recipe_runtime::systemd; +use crate::recipe_store::Artifact as RecipeRuntimeArtifact; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct MaterializedExecutionPlan { + pub execution_kind: String, + pub unit_name: String, + pub commands: Vec>, + pub resources: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", default)] +pub struct ExecutionRoute { + pub runner: String, + pub target_kind: String, + pub host_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipeRequest { + pub spec: ExecutionSpec, + #[serde(default)] + pub source_origin: Option, + #[serde(default)] + pub source_text: Option, + #[serde(default)] + pub workspace_slug: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipePrepared { + pub run_id: String, + pub route: ExecutionRoute, + pub plan: MaterializedExecutionPlan, + pub summary: String, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteRecipeResult { + pub run_id: String, + pub instance_id: String, + pub summary: String, + pub warnings: Vec, +} + +fn has_command_value(value: Option<&Value>) -> bool { + value + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) +} + +fn has_structured_job_command(spec: &ExecutionSpec) -> bool { + has_command_value(spec.desired_state.get("command")) + || spec + .desired_state + .get("job") + .and_then(|value| value.get("command")) + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) + || spec.actions.iter().any(|action| { + action + .args + .get("command") + .and_then(Value::as_array) + .is_some_and(|parts| !parts.is_empty()) + }) +} + +fn has_structured_schedule(spec: &ExecutionSpec) -> bool { + spec.desired_state + .get("schedule") + .and_then(|value| value.get("onCalendar")) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || spec.actions.iter().any(|action| { + action + .args + .get("onCalendar") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }) +} + +fn has_structured_attachment_state(spec: &ExecutionSpec) -> bool { + spec.desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + .is_some() + || spec + .desired_state + .get("envPatch") + .and_then(Value::as_object) + .is_some() +} + +fn collect_claim_resource_refs(spec: &ExecutionSpec) -> Vec { + let mut refs = Vec::new(); + for claim in &spec.resources.claims { + for value in [&claim.id, &claim.target, &claim.path] { + if let Some(value) = value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if !refs.iter().any(|existing| existing == value) { + refs.push(value.to_string()); + } + } + } + } + refs +} + +fn action_only_materialized_plan(spec: &ExecutionSpec) -> MaterializedExecutionPlan { + MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: String::new(), + commands: Vec::new(), + resources: collect_claim_resource_refs(spec), + warnings: Vec::new(), + } +} + +fn summary_subject(spec: &ExecutionSpec, plan: &MaterializedExecutionPlan) -> String { + if !plan.unit_name.trim().is_empty() { + return plan.unit_name.clone(); + } + + spec.metadata + .name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "recipe".into()) +} + +fn presented_summary(spec: &ExecutionSpec) -> Option { + spec.source + .get("recipePresentation") + .and_then(|value| value.get("resultSummary")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) +} + +pub fn materialize_execution_plan( + spec: &ExecutionSpec, +) -> Result { + match spec.execution.kind.as_str() { + "job" if has_structured_job_command(spec) => { + let runtime_plan = systemd::materialize_job(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "service" if has_structured_job_command(spec) => { + let runtime_plan = systemd::materialize_service(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "schedule" if has_structured_job_command(spec) && has_structured_schedule(spec) => { + let runtime_plan = systemd::materialize_schedule(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "attachment" if has_structured_attachment_state(spec) => { + let runtime_plan = systemd::materialize_attachment(spec)?; + Ok(MaterializedExecutionPlan { + execution_kind: spec.execution.kind.clone(), + unit_name: runtime_plan.unit_name, + commands: runtime_plan.commands, + resources: runtime_plan.resources, + warnings: runtime_plan.warnings, + }) + } + "job" | "attachment" if !spec.actions.is_empty() => Ok(action_only_materialized_plan(spec)), + other => Err(format!("unsupported execution kind: {}", other)), + } +} + +pub fn route_execution(target: &Value) -> Result { + let target_kind = target + .get("kind") + .and_then(Value::as_str) + .unwrap_or("local") + .to_string(); + + match target_kind.as_str() { + "local" | "docker_local" => Ok(ExecutionRoute { + runner: "local".into(), + target_kind, + host_id: None, + }), + "remote" | "remote_ssh" => Ok(ExecutionRoute { + runner: "remote_ssh".into(), + target_kind, + host_id: target + .get("hostId") + .and_then(Value::as_str) + .map(|value| value.to_string()), + }), + other => Err(format!("unsupported execution target kind: {}", other)), + } +} + +fn push_unique_artifact( + artifacts: &mut Vec, + artifact: RecipeRuntimeArtifact, +) { + if !artifacts.iter().any(|existing| { + existing.kind == artifact.kind + && existing.label == artifact.label + && existing.path == artifact.path + }) { + artifacts.push(artifact); + } +} + +fn push_unique_command(commands: &mut Vec>, command: Vec) { + if !commands.iter().any(|existing| existing == &command) { + commands.push(command); + } +} + +pub fn build_runtime_artifacts( + spec: &ExecutionSpec, + prepared: &ExecuteRecipePrepared, +) -> Vec { + let mut artifacts = Vec::new(); + let unit_name = prepared.plan.unit_name.trim(); + + match spec.execution.kind.as_str() { + "job" | "service" if !unit_name.is_empty() => { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:unit", prepared.run_id), + kind: "systemdUnit".into(), + label: prepared.plan.unit_name.clone(), + path: Some(prepared.plan.unit_name.clone()), + }, + ); + } + "schedule" if !unit_name.is_empty() => { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:unit", prepared.run_id), + kind: "systemdUnit".into(), + label: prepared.plan.unit_name.clone(), + path: Some(prepared.plan.unit_name.clone()), + }, + ); + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:timer", prepared.run_id), + kind: "systemdTimer".into(), + label: format!("{}.timer", prepared.plan.unit_name), + path: Some(format!("{}.timer", prepared.plan.unit_name)), + }, + ); + } + "attachment" => { + if systemd::render_env_patch_dropin_content(spec).is_some() { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:daemon-reload", prepared.run_id), + kind: "systemdDaemonReload".into(), + label: "systemctl --user daemon-reload".into(), + path: None, + }, + ); + } + + if let Some(path) = systemd::env_patch_dropin_path(spec) { + if let Some(target) = systemd::attachment_target_unit(spec) { + let name = systemd::env_patch_dropin_name(spec); + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:env-dropin", prepared.run_id), + kind: "systemdDropIn".into(), + label: format!("{}:{}", target, name), + path: Some(path), + }, + ); + } + } + + if let Some(drop_in) = spec + .desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + { + let target = drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let name = drop_in + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let (Some(target), Some(name)) = (target, name) { + push_unique_artifact( + &mut artifacts, + RecipeRuntimeArtifact { + id: format!("{}:dropin", prepared.run_id), + kind: "systemdDropIn".into(), + label: format!("{}:{}", target, name), + path: Some(format!("~/.config/systemd/user/{}.d/{}", target, name)), + }, + ); + } + } + } + _ => {} + } + + artifacts +} + +pub fn build_cleanup_commands(artifacts: &[RecipeRuntimeArtifact]) -> Vec> { + let mut commands = Vec::new(); + + for artifact in artifacts { + match artifact.kind.as_str() { + "systemdUnit" | "systemdTimer" => { + let target = artifact + .path + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&artifact.label); + push_unique_command( + &mut commands, + vec![ + "systemctl".into(), + "--user".into(), + "stop".into(), + target.to_string(), + ], + ); + push_unique_command( + &mut commands, + vec![ + "systemctl".into(), + "--user".into(), + "reset-failed".into(), + target.to_string(), + ], + ); + } + "systemdDaemonReload" => { + push_unique_command( + &mut commands, + vec!["systemctl".into(), "--user".into(), "daemon-reload".into()], + ); + } + _ => {} + } + } + + commands +} + +pub fn execute_recipe(request: ExecuteRecipeRequest) -> Result { + let plan = materialize_execution_plan(&request.spec)?; + let route = route_execution(&request.spec.target)?; + let operation_count = if !plan.commands.is_empty() { + plan.commands.len() + } else { + request.spec.actions.len() + }; + let operation_label = if !plan.commands.is_empty() { + "command" + } else { + "action" + }; + let summary = presented_summary(&request.spec).unwrap_or_else(|| { + format!( + "{} via {} ({} {}{})", + summary_subject(&request.spec, &plan), + route.runner, + operation_count, + operation_label, + if operation_count == 1 { "" } else { "s" } + ) + }); + + let warnings = plan.warnings.clone(); + + Ok(ExecuteRecipePrepared { + run_id: Uuid::new_v4().to_string(), + route, + plan, + summary, + warnings, + }) +} diff --git a/src-tauri/src/recipe_executor_tests.rs b/src-tauri/src/recipe_executor_tests.rs new file mode 100644 index 00000000..c945c971 --- /dev/null +++ b/src-tauri/src/recipe_executor_tests.rs @@ -0,0 +1,422 @@ +use serde_json::{json, Value}; + +use crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND; +use crate::execution_spec::{ + ExecutionAction, ExecutionCapabilities, ExecutionMetadata, ExecutionResourceClaim, + ExecutionResources, ExecutionSecrets, ExecutionSpec, ExecutionTarget, +}; +use crate::recipe_executor::{ + build_cleanup_commands, build_runtime_artifacts, execute_recipe, materialize_execution_plan, + route_execution, ExecuteRecipeRequest, +}; +use crate::recipe_store::Artifact; + +fn sample_target(kind: &str) -> Value { + match kind { + "remote" => json!({ + "kind": "remote", + "hostId": "ssh:prod-a", + }), + _ => json!({ + "kind": "local", + }), + } +} + +fn sample_job_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-health-check".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { kind: "job".into() }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("openclaw-gateway".into()), + target: None, + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "command": ["openclaw", "doctor", "run"], + }), + actions: vec![ExecutionAction { + kind: Some("job".into()), + name: Some("Run doctor".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + }), + }], + outputs: vec![], + } +} + +fn sample_schedule_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("hourly-reconcile".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "schedule".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("schedule/hourly".into()), + target: Some("job/hourly-reconcile".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "schedule": { + "id": "schedule/hourly", + "onCalendar": "hourly", + }, + "job": { + "command": ["openclaw", "doctor", "run"], + } + }), + actions: vec![ExecutionAction { + kind: Some("schedule".into()), + name: Some("Run hourly reconcile".into()), + args: json!({ + "command": ["openclaw", "doctor", "run"], + "onCalendar": "hourly", + }), + }], + outputs: vec![], + } +} + +fn sample_execution_request() -> ExecuteRecipeRequest { + ExecuteRecipeRequest { + spec: sample_job_spec(), + source_origin: None, + source_text: None, + workspace_slug: None, + } +} + +fn sample_presented_execution_request() -> ExecuteRecipeRequest { + let mut spec = sample_job_spec(); + spec.source = json!({ + "recipeId": "agent-persona-pack", + "recipePresentation": { + "resultSummary": "Updated persona for main" + } + }); + ExecuteRecipeRequest { + spec, + source_origin: None, + source_text: None, + workspace_slug: None, + } +} + +fn sample_attachment_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("gateway-env".into()), + digest: None, + }, + source: Value::Null, + target: json!({ "kind": "local" }), + execution: ExecutionTarget { + kind: "attachment".into(), + }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["service.manage".into()], + }, + resources: ExecutionResources { + claims: vec![ExecutionResourceClaim { + kind: "service".into(), + id: Some("openclaw-gateway".into()), + target: Some("openclaw-gateway.service".into()), + path: None, + }], + }, + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "systemdDropIn": { + "unit": "openclaw-gateway.service", + "name": "10-channel.conf", + "content": "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord\n", + }, + "envPatch": { + "OPENCLAW_CHANNEL": "discord", + } + }), + actions: vec![ExecutionAction { + kind: Some("attachment".into()), + name: Some("Apply gateway env".into()), + args: json!({}), + }], + outputs: vec![], + } +} + +fn sample_action_recipe_spec() -> ExecutionSpec { + ExecutionSpec { + api_version: "strategy.platform/v1".into(), + kind: "ExecutionSpec".into(), + metadata: ExecutionMetadata { + name: Some("discord-channel-persona".into()), + digest: None, + }, + source: json!({ + "recipeId": "discord-channel-persona", + "recipeVersion": "1.0.0", + }), + target: json!({ "kind": "local" }), + execution: ExecutionTarget { kind: "job".into() }, + capabilities: ExecutionCapabilities { + used_capabilities: vec!["config.write".into()], + }, + resources: ExecutionResources::default(), + secrets: ExecutionSecrets::default(), + desired_state: json!({ + "actionCount": 1, + }), + actions: vec![ExecutionAction { + kind: Some("config_patch".into()), + name: Some("Set channel persona".into()), + args: json!({ + "patch": { + "channels": { + "discord": { + "guilds": { + "guild-1": { + "channels": { + "channel-1": { + "systemPrompt": "Keep answers concise" + } + } + } + } + } + } + } + }), + }], + outputs: vec![json!({ + "kind": "recipe-summary", + "recipeId": "discord-channel-persona", + })], + } +} + +#[test] +fn job_spec_materializes_to_systemd_run_command() { + let spec = sample_job_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize execution plan"); + + assert!(plan + .commands + .iter() + .any(|cmd| cmd.join(" ").contains("systemd-run"))); +} + +#[test] +fn schedule_spec_references_job_launch_ref() { + let spec = sample_schedule_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize execution plan"); + + assert!(plan + .resources + .iter() + .any(|ref_id| ref_id == "schedule/hourly")); +} + +#[test] +fn local_target_uses_local_runner() { + let route = route_execution(&sample_target("local")).expect("route execution"); + + assert_eq!(route.runner, "local"); +} + +#[test] +fn remote_target_uses_remote_ssh_runner() { + let route = route_execution(&sample_target("remote")).expect("route execution"); + + assert_eq!(route.runner, "remote_ssh"); +} + +#[test] +fn execute_recipe_returns_run_id_and_summary() { + let result = execute_recipe(sample_execution_request()).expect("execute recipe"); + + assert!(!result.run_id.is_empty()); + assert!(!result.summary.is_empty()); +} + +#[test] +fn execute_recipe_prefers_recipe_presentation_summary() { + let result = + execute_recipe(sample_presented_execution_request()).expect("execute recipe with summary"); + + assert_eq!(result.summary, "Updated persona for main"); +} + +#[test] +fn action_recipe_spec_can_prepare_without_command_payload() { + let result = execute_recipe(ExecuteRecipeRequest { + spec: sample_action_recipe_spec(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare action recipe execution"); + + assert!(!result.run_id.is_empty()); + assert!(result.summary.contains("discord-channel-persona")); +} + +#[test] +fn attachment_spec_materializes_dropin_write_and_daemon_reload() { + let spec = sample_attachment_spec(); + let plan = materialize_execution_plan(&spec).expect("materialize attachment execution plan"); + + assert_eq!( + plan.commands[0], + vec![ + INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.to_string(), + "openclaw-gateway.service".to_string(), + "10-channel.conf".to_string(), + "[Service]\nEnvironment=OPENCLAW_CHANNEL=discord\n".to_string(), + ] + ); + assert!(plan.commands.iter().any(|command| { + command + == &vec![ + INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.to_string(), + "openclaw-gateway.service".to_string(), + "90-clawpal-env-gateway-env.conf".to_string(), + "[Service]\nEnvironment=\"OPENCLAW_CHANNEL=discord\"\n".to_string(), + ] + })); + assert!(plan.commands.iter().any(|command| { + command + == &vec![ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ] + })); +} + +#[test] +fn schedule_execution_builds_unit_and_timer_artifacts() { + let spec = sample_schedule_spec(); + let prepared = execute_recipe(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare schedule execution"); + + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts.iter().any( + |artifact| artifact.kind == "systemdUnit" && artifact.label == prepared.plan.unit_name + )); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdTimer")); +} + +#[test] +fn attachment_execution_builds_dropin_and_reload_artifacts() { + let spec = sample_attachment_spec(); + let prepared = execute_recipe(ExecuteRecipeRequest { + spec: spec.clone(), + source_origin: None, + source_text: None, + workspace_slug: None, + }) + .expect("prepare attachment execution"); + + let artifacts = build_runtime_artifacts(&spec, &prepared); + + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDropIn" + && artifact.path.as_deref() + == Some("~/.config/systemd/user/openclaw-gateway.service.d/10-channel.conf"))); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDropIn" + && artifact.path.as_deref() + == Some("~/.config/systemd/user/openclaw-gateway.service.d/90-clawpal-env-gateway-env.conf"))); + assert!(artifacts + .iter() + .any(|artifact| artifact.kind == "systemdDaemonReload")); +} + +#[test] +fn cleanup_commands_stop_and_reset_failed_for_systemd_artifacts() { + let commands = build_cleanup_commands(&[ + Artifact { + id: "run_01:unit".into(), + kind: "systemdUnit".into(), + label: "clawpal-job-hourly".into(), + path: Some("clawpal-job-hourly".into()), + }, + Artifact { + id: "run_01:timer".into(), + kind: "systemdTimer".into(), + label: "clawpal-job-hourly.timer".into(), + path: Some("clawpal-job-hourly.timer".into()), + }, + ]); + + assert_eq!( + commands, + vec![ + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("stop"), + String::from("clawpal-job-hourly"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("reset-failed"), + String::from("clawpal-job-hourly"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("stop"), + String::from("clawpal-job-hourly.timer"), + ], + vec![ + String::from("systemctl"), + String::from("--user"), + String::from("reset-failed"), + String::from("clawpal-job-hourly.timer"), + ], + ] + ); +} diff --git a/src-tauri/src/recipe_library.rs b/src-tauri/src/recipe_library.rs new file mode 100644 index 00000000..977a8532 --- /dev/null +++ b/src-tauri/src/recipe_library.rs @@ -0,0 +1,884 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use tauri::Manager; + +use crate::recipe::{ + load_recipes_from_source, load_recipes_from_source_text, validate_recipe_source, +}; +use crate::recipe_adapter::export_recipe_source as export_recipe_source_document; +use crate::recipe_workspace::{ + BundledRecipeDescriptor, BundledRecipeState, RecipeWorkspace, RecipeWorkspaceSourceKind, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ImportedRecipe { + pub slug: String, + pub recipe_id: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkippedRecipeImport { + pub recipe_dir: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeLibraryImportResult { + #[serde(default)] + pub imported: Vec, + #[serde(default)] + pub skipped: Vec, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeImportConflict { + pub slug: String, + pub recipe_id: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkippedRecipeSourceImport { + pub source: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RecipeImportSourceKind { + LocalFile, + LocalRecipeDirectory, + LocalRecipeLibrary, + RemoteUrl, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceImportResult { + pub source_kind: Option, + #[serde(default)] + pub imported: Vec, + #[serde(default)] + pub skipped: Vec, + #[serde(default)] + pub warnings: Vec, + #[serde(default)] + pub conflicts: Vec, +} + +#[derive(Debug, Clone)] +struct PreparedRecipeImport { + slug: String, + recipe_id: String, + source_text: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct BundledRecipeSource { + pub recipe_id: String, + pub version: String, + pub source_text: String, + pub digest: String, +} + +pub fn import_recipe_library( + root: &Path, + workspace: &RecipeWorkspace, +) -> Result { + let recipe_dirs = collect_recipe_dirs(root)?; + let mut result = RecipeLibraryImportResult::default(); + let mut seen_recipe_ids = std::collections::BTreeSet::new(); + let mut seen_slugs = workspace + .list_entries()? + .into_iter() + .map(|entry| entry.slug) + .collect::>(); + for recipe_dir in recipe_dirs { + match import_recipe_dir( + &recipe_dir, + workspace, + &mut seen_recipe_ids, + &mut seen_slugs, + ) { + Ok(imported) => result.imported.push(imported), + Err(error) => result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: error, + }), + } + } + + Ok(result) +} + +pub fn seed_recipe_library( + root: &Path, + workspace: &RecipeWorkspace, +) -> Result { + let recipe_dirs = collect_recipe_dirs(root)?; + let mut seen_slugs = std::collections::BTreeSet::new(); + let mut seen_recipe_ids = std::collections::BTreeSet::new(); + let mut result = RecipeLibraryImportResult::default(); + + for recipe_dir in recipe_dirs { + let recipe_path = recipe_dir.join("recipe.json"); + if !recipe_path.exists() { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: "recipe.json not found".into(), + }); + continue; + } + + let source = match fs::read_to_string(&recipe_path) { + Ok(source) => source, + Err(error) => { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: format!( + "failed to read recipe source '{}': {}", + recipe_path.to_string_lossy(), + error + ), + }); + continue; + } + }; + let (recipe_id, compiled_source) = match compile_recipe_source(&recipe_dir, &source) { + Ok(compiled) => compiled, + Err(error) => { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: error, + }); + continue; + } + }; + let slug = match crate::recipe_workspace::normalize_recipe_slug(&recipe_id) { + Ok(slug) => slug, + Err(error) => { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: error, + }); + continue; + } + }; + + if !seen_recipe_ids.insert(recipe_id.clone()) { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: format!("duplicate recipe id '{}'", recipe_id), + }); + continue; + } + + if !seen_slugs.insert(slug.clone()) { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: format!("duplicate recipe slug '{}'", slug), + }); + continue; + } + + let diagnostics = validate_recipe_source(&compiled_source)?; + if !diagnostics.errors.is_empty() { + result.skipped.push(SkippedRecipeImport { + recipe_dir: recipe_dir.to_string_lossy().to_string(), + reason: diagnostics + .errors + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect::>() + .join("; "), + }); + continue; + } + + match workspace.bundled_recipe_state(&slug, &compiled_source) { + Ok(BundledRecipeState::UpToDate | BundledRecipeState::UpdateAvailable) => continue, + Ok(BundledRecipeState::LocalModified | BundledRecipeState::ConflictedUpdate) => { + result.warnings.push(format!( + "Skipped bundled recipe '{}' because workspace recipe '{}' was modified locally.", + recipe_id, slug + )); + continue; + } + Ok(BundledRecipeState::Missing) | Err(_) => { + if workspace + .resolve_recipe_source_path(&slug) + .ok() + .is_some_and(|path| Path::new(&path).exists()) + { + result.warnings.push(format!( + "Skipped bundled recipe '{}' because workspace recipe '{}' already exists.", + recipe_id, slug + )); + continue; + } + } + } + + let version = load_recipes_from_source_text(&compiled_source)? + .into_iter() + .next() + .map(|recipe| recipe.version) + .unwrap_or_else(|| "0.0.0".into()); + let saved = + workspace.save_bundled_recipe_source(&slug, &compiled_source, &recipe_id, &version)?; + result.imported.push(ImportedRecipe { + slug: saved.slug, + recipe_id, + path: saved.path, + }); + } + + Ok(result) +} + +pub fn import_recipe_source( + source: &str, + workspace: &RecipeWorkspace, + overwrite_existing: bool, +) -> Result { + let trimmed = source.trim(); + if trimmed.is_empty() { + return Err("recipe import source cannot be empty".into()); + } + + let prepared = prepare_recipe_imports(trimmed)?; + let import_source_kind = workspace_source_kind_for_import(prepared.source_kind.clone()); + let mut result = RecipeSourceImportResult { + source_kind: Some(prepared.source_kind.clone()), + skipped: prepared.skipped, + warnings: prepared.warnings, + ..RecipeSourceImportResult::default() + }; + + let existing = workspace + .list_entries()? + .into_iter() + .map(|entry| (entry.slug, entry.path)) + .collect::>(); + + if !overwrite_existing { + result.conflicts = prepared + .items + .iter() + .filter_map(|item| { + existing.get(&item.slug).map(|path| RecipeImportConflict { + slug: item.slug.clone(), + recipe_id: item.recipe_id.clone(), + path: path.clone(), + }) + }) + .collect(); + if !result.conflicts.is_empty() { + return Ok(result); + } + } + + for item in prepared.items { + let saved = workspace.save_imported_recipe_source( + &item.slug, + &item.source_text, + import_source_kind.clone(), + )?; + result.imported.push(ImportedRecipe { + slug: saved.slug, + recipe_id: item.recipe_id, + path: saved.path, + }); + } + + Ok(result) +} + +pub fn seed_bundled_recipe_library( + app_handle: &tauri::AppHandle, +) -> Result { + let root = resolve_bundled_recipe_library_root(app_handle)?; + let workspace = RecipeWorkspace::from_resolved_paths(); + seed_recipe_library(&root, &workspace) +} + +pub fn upgrade_bundled_recipe( + app_handle: &tauri::AppHandle, + workspace: &RecipeWorkspace, + slug: &str, +) -> Result { + let sources = load_bundled_recipe_sources(app_handle)?; + let bundled = sources + .get(slug) + .ok_or_else(|| format!("bundled recipe '{}' not found", slug))?; + match workspace.bundled_recipe_state(slug, &bundled.source_text)? { + BundledRecipeState::UpdateAvailable | BundledRecipeState::Missing => {} + BundledRecipeState::UpToDate => { + return Err(format!("bundled recipe '{}' is already up to date", slug)); + } + BundledRecipeState::LocalModified => { + return Err(format!( + "bundled recipe '{}' has local changes and must be reviewed before replacing", + slug + )); + } + BundledRecipeState::ConflictedUpdate => { + return Err(format!( + "bundled recipe '{}' has local changes and a newer bundled version", + slug + )); + } + } + workspace.save_bundled_recipe_source( + slug, + &bundled.source_text, + &bundled.recipe_id, + &bundled.version, + ) +} + +pub(crate) fn load_bundled_recipe_descriptors( + app_handle: &tauri::AppHandle, +) -> Result, String> { + Ok(load_bundled_recipe_sources(app_handle)? + .into_iter() + .map(|(slug, source)| { + ( + slug, + BundledRecipeDescriptor { + recipe_id: source.recipe_id, + version: source.version, + digest: source.digest, + }, + ) + }) + .collect()) +} + +fn resolve_bundled_recipe_library_root(app_handle: &tauri::AppHandle) -> Result { + let candidates = bundled_recipe_library_candidates(app_handle); + select_recipe_library_root(candidates) +} + +pub(crate) fn bundled_recipe_library_candidates(app_handle: &tauri::AppHandle) -> Vec { + let mut candidates = Vec::new(); + + if let Ok(resource_root) = app_handle + .path() + .resolve("recipe-library", tauri::path::BaseDirectory::Resource) + { + candidates.push(resource_root); + } + + if let Ok(resource_root) = app_handle.path().resolve( + "examples/recipe-library", + tauri::path::BaseDirectory::Resource, + ) { + candidates.push(resource_root); + } + + if let Ok(resource_root) = app_handle + .path() + .resolve("_up_/recipe-library", tauri::path::BaseDirectory::Resource) + { + candidates.push(resource_root); + } + + if let Ok(resource_root) = app_handle.path().resolve( + "_up_/examples/recipe-library", + tauri::path::BaseDirectory::Resource, + ) { + candidates.push(resource_root); + } + + candidates.push(dev_recipe_library_root()); + dedupe_paths(candidates) +} + +pub(crate) fn dev_recipe_library_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") + .join("recipe-library") +} + +pub(crate) fn select_recipe_library_root(candidates: Vec) -> Result { + candidates + .iter() + .find(|path| looks_like_recipe_library_root(path)) + .cloned() + .ok_or_else(|| { + let joined = candidates + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join(", "); + format!( + "bundled recipe library resource not found; checked: {}", + joined + ) + }) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + let mut deduped = Vec::new(); + for path in paths { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + deduped.push(path); + } + } + deduped +} + +pub(crate) fn looks_like_recipe_library_root(path: &Path) -> bool { + if !path.is_dir() { + return false; + } + + let entries = match fs::read_dir(path) { + Ok(entries) => entries, + Err(_) => return false, + }; + + entries.flatten().any(|entry| { + let recipe_dir = entry.path(); + recipe_dir.is_dir() && recipe_dir.join("recipe.json").is_file() + }) +} + +fn collect_recipe_dirs(root: &Path) -> Result, String> { + if !root.exists() { + return Err(format!( + "recipe library root does not exist: {}", + root.to_string_lossy() + )); + } + if !root.is_dir() { + return Err(format!( + "recipe library root is not a directory: {}", + root.to_string_lossy() + )); + } + + let mut recipe_dirs = Vec::new(); + for entry in fs::read_dir(root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if path.is_dir() { + recipe_dirs.push(path); + } + } + recipe_dirs.sort(); + Ok(recipe_dirs) +} + +fn import_recipe_dir( + recipe_dir: &Path, + workspace: &RecipeWorkspace, + seen_recipe_ids: &mut std::collections::BTreeSet, + seen_slugs: &mut std::collections::BTreeSet, +) -> Result { + let (recipe_id, compiled_source) = compile_recipe_directory_source(recipe_dir)?; + let slug = crate::recipe_workspace::normalize_recipe_slug(&recipe_id)?; + if !seen_recipe_ids.insert(recipe_id.clone()) { + return Err(format!("duplicate recipe id '{}'", recipe_id)); + } + if !seen_slugs.insert(slug.clone()) { + return Err(format!("duplicate recipe slug '{}'", slug)); + } + let diagnostics = validate_recipe_source(&compiled_source)?; + if !diagnostics.errors.is_empty() { + return Err(diagnostics + .errors + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect::>() + .join("; ")); + } + + let saved = workspace.save_imported_recipe_source( + &slug, + &compiled_source, + RecipeWorkspaceSourceKind::LocalImport, + )?; + Ok(ImportedRecipe { + slug: saved.slug, + recipe_id, + path: saved.path, + }) +} + +fn load_bundled_recipe_sources( + app_handle: &tauri::AppHandle, +) -> Result, String> { + let root = resolve_bundled_recipe_library_root(app_handle)?; + load_bundled_recipe_sources_from_root(&root) +} + +fn load_bundled_recipe_sources_from_root( + root: &Path, +) -> Result, String> { + let mut sources = BTreeMap::new(); + for recipe_dir in collect_recipe_dirs(root)? { + let (recipe_id, compiled_source) = compile_recipe_directory_source(&recipe_dir)?; + let slug = crate::recipe_workspace::normalize_recipe_slug(&recipe_id)?; + let version = load_recipes_from_source_text(&compiled_source)? + .into_iter() + .next() + .map(|recipe| recipe.version) + .unwrap_or_else(|| "0.0.0".into()); + sources.insert( + slug.clone(), + BundledRecipeSource { + recipe_id, + version, + digest: RecipeWorkspace::source_digest(&compiled_source), + source_text: compiled_source, + }, + ); + } + Ok(sources) +} + +fn workspace_source_kind_for_import( + source_kind: RecipeImportSourceKind, +) -> RecipeWorkspaceSourceKind { + match source_kind { + RecipeImportSourceKind::RemoteUrl => RecipeWorkspaceSourceKind::RemoteUrl, + RecipeImportSourceKind::LocalFile + | RecipeImportSourceKind::LocalRecipeDirectory + | RecipeImportSourceKind::LocalRecipeLibrary => RecipeWorkspaceSourceKind::LocalImport, + } +} + +pub(crate) fn compile_recipe_directory_source( + recipe_dir: &Path, +) -> Result<(String, String), String> { + let recipe_path = recipe_dir.join("recipe.json"); + if !recipe_path.exists() { + return Err("recipe.json not found".into()); + } + + let source = fs::read_to_string(&recipe_path).map_err(|error| { + format!( + "failed to read recipe source '{}': {}", + recipe_path.to_string_lossy(), + error + ) + })?; + + compile_recipe_source(recipe_dir, &source) +} + +fn prepare_recipe_imports(source: &str) -> Result { + if looks_like_http_source(source) { + return prepare_imports_from_loaded_recipes( + RecipeImportSourceKind::RemoteUrl, + source, + source, + ); + } + + let path = PathBuf::from(shellexpand::tilde(source).to_string()); + if path.is_dir() { + if looks_like_recipe_library_root(&path) { + return prepare_imports_from_recipe_library(&path); + } + if path.join("recipe.json").is_file() { + return prepare_imports_from_loaded_recipes( + RecipeImportSourceKind::LocalRecipeDirectory, + source, + &path.to_string_lossy(), + ); + } + return Err(format!( + "recipe source directory is neither a recipe folder nor a recipe library root: {}", + path.to_string_lossy() + )); + } + + prepare_imports_from_loaded_recipes( + RecipeImportSourceKind::LocalFile, + source, + &path.to_string_lossy(), + ) +} + +struct PreparedRecipeImports { + source_kind: RecipeImportSourceKind, + items: Vec, + skipped: Vec, + warnings: Vec, +} + +fn prepare_imports_from_loaded_recipes( + source_kind: RecipeImportSourceKind, + raw_source: &str, + source_ref: &str, +) -> Result { + let recipes = load_recipes_from_source(raw_source)?; + let mut seen_recipe_ids = std::collections::BTreeSet::new(); + let mut seen_slugs = std::collections::BTreeSet::new(); + let mut items = Vec::new(); + let mut skipped = Vec::new(); + + for recipe in recipes { + let recipe_id = recipe.id.trim().to_string(); + let slug = crate::recipe_workspace::normalize_recipe_slug(&recipe_id)?; + if !seen_recipe_ids.insert(recipe_id.clone()) { + skipped.push(SkippedRecipeSourceImport { + source: source_ref.to_string(), + reason: format!("duplicate recipe id '{}'", recipe_id), + }); + continue; + } + if !seen_slugs.insert(slug.clone()) { + skipped.push(SkippedRecipeSourceImport { + source: source_ref.to_string(), + reason: format!("duplicate recipe slug '{}'", slug), + }); + continue; + } + let source_text = export_recipe_source_document(&recipe)?; + items.push(PreparedRecipeImport { + slug, + recipe_id, + source_text, + }); + } + + Ok(PreparedRecipeImports { + source_kind, + items, + skipped, + warnings: Vec::new(), + }) +} + +fn prepare_imports_from_recipe_library(root: &Path) -> Result { + let recipe_dirs = collect_recipe_dirs(root)?; + let mut seen_recipe_ids = std::collections::BTreeSet::new(); + let mut seen_slugs = std::collections::BTreeSet::new(); + let mut items = Vec::new(); + let mut skipped = Vec::new(); + + for recipe_dir in recipe_dirs { + match compile_recipe_directory_source(&recipe_dir) { + Ok((recipe_id, compiled_source)) => { + let slug = crate::recipe_workspace::normalize_recipe_slug(&recipe_id)?; + if !seen_recipe_ids.insert(recipe_id.clone()) { + skipped.push(SkippedRecipeSourceImport { + source: recipe_dir.to_string_lossy().to_string(), + reason: format!("duplicate recipe id '{}'", recipe_id), + }); + continue; + } + if !seen_slugs.insert(slug.clone()) { + skipped.push(SkippedRecipeSourceImport { + source: recipe_dir.to_string_lossy().to_string(), + reason: format!("duplicate recipe slug '{}'", slug), + }); + continue; + } + let diagnostics = validate_recipe_source(&compiled_source)?; + if !diagnostics.errors.is_empty() { + skipped.push(SkippedRecipeSourceImport { + source: recipe_dir.to_string_lossy().to_string(), + reason: diagnostics + .errors + .iter() + .map(|diagnostic| diagnostic.message.clone()) + .collect::>() + .join("; "), + }); + continue; + } + items.push(PreparedRecipeImport { + slug, + recipe_id, + source_text: compiled_source, + }); + } + Err(error) => skipped.push(SkippedRecipeSourceImport { + source: recipe_dir.to_string_lossy().to_string(), + reason: error, + }), + } + } + + Ok(PreparedRecipeImports { + source_kind: RecipeImportSourceKind::LocalRecipeLibrary, + items, + skipped, + warnings: Vec::new(), + }) +} + +fn looks_like_http_source(source: &str) -> bool { + let trimmed = source.trim(); + trimmed.starts_with("http://") || trimmed.starts_with("https://") +} + +fn compile_recipe_source(recipe_dir: &Path, source: &str) -> Result<(String, String), String> { + let mut document: Value = json5::from_str(source).map_err(|error| error.to_string())?; + let recipe = document + .as_object_mut() + .ok_or_else(|| "recipe.json must contain a single recipe object".to_string())?; + + let preset_specs = compile_preset_specs(recipe_dir, recipe.get("clawpalImport"))?; + if !preset_specs.is_empty() { + inject_param_options(recipe, &preset_specs)?; + inject_preset_maps(recipe, &preset_specs); + } else { + recipe.remove("clawpalImport"); + } + let recipe = document + .as_object_mut() + .ok_or_else(|| "compiled recipe document must stay as an object".to_string())?; + recipe.remove("clawpalImport"); + + let recipe_id = document + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "recipe.id is required".to_string())? + .to_string(); + + let compiled = serde_json::to_string_pretty(&document).map_err(|error| error.to_string())?; + Ok((recipe_id, compiled)) +} + +#[derive(Debug, Clone)] +struct PresetSpec { + options: Vec, + values: Map, +} + +fn compile_preset_specs( + recipe_dir: &Path, + clawpal_import: Option<&Value>, +) -> Result, String> { + let mut result = BTreeMap::new(); + let Some(import_object) = clawpal_import.and_then(Value::as_object) else { + return Ok(result); + }; + let Some(preset_params) = import_object.get("presetParams").and_then(Value::as_object) else { + return Ok(result); + }; + + for (param_id, entries) in preset_params { + let entries = entries + .as_array() + .ok_or_else(|| format!("clawpalImport.presetParams.{} must be an array", param_id))?; + let mut options = Vec::new(); + let mut values = Map::new(); + + for entry in entries { + let entry = entry.as_object().ok_or_else(|| { + format!( + "clawpalImport.presetParams.{} entries must be objects", + param_id + ) + })?; + let value = required_string(entry, "value", param_id)?; + let label = required_string(entry, "label", param_id)?; + let asset = required_string(entry, "asset", param_id)?; + let asset_path = recipe_dir.join(&asset); + if !asset_path.exists() { + return Err(format!( + "missing asset '{}' for preset param '{}'", + asset, param_id + )); + } + let text = fs::read_to_string(&asset_path).map_err(|error| { + format!( + "failed to read asset '{}' for preset param '{}': {}", + asset, param_id, error + ) + })?; + + options.push(serde_json::json!({ + "value": value, + "label": label, + })); + values.insert(value, Value::String(text)); + } + + result.insert(param_id.clone(), PresetSpec { options, values }); + } + + Ok(result) +} + +fn required_string( + entry: &Map, + field: &str, + param_id: &str, +) -> Result { + entry + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + format!( + "clawpalImport.presetParams.{} entry is missing '{}'", + param_id, field + ) + }) +} + +fn inject_param_options( + recipe: &mut Map, + preset_specs: &BTreeMap, +) -> Result<(), String> { + let params = recipe + .get_mut("params") + .and_then(Value::as_array_mut) + .ok_or_else(|| "recipe.params must be an array".to_string())?; + + for (param_id, spec) in preset_specs { + let Some(param) = params + .iter_mut() + .find(|param| param.get("id").and_then(Value::as_str) == Some(param_id.as_str())) + else { + return Err(format!( + "clawpalImport.presetParams references unknown param '{}'", + param_id + )); + }; + let param_object = param + .as_object_mut() + .ok_or_else(|| format!("param '{}' must be an object", param_id))?; + param_object.insert("options".into(), Value::Array(spec.options.clone())); + } + + Ok(()) +} + +fn inject_preset_maps( + recipe: &mut Map, + preset_specs: &BTreeMap, +) { + let preset_maps = preset_specs + .iter() + .map(|(param_id, spec)| (param_id.clone(), Value::Object(spec.values.clone()))) + .collect(); + recipe.insert("clawpalPresetMaps".into(), Value::Object(preset_maps)); +} diff --git a/src-tauri/src/recipe_library_tests.rs b/src-tauri/src/recipe_library_tests.rs new file mode 100644 index 00000000..bc4826c0 --- /dev/null +++ b/src-tauri/src/recipe_library_tests.rs @@ -0,0 +1,861 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::recipe::load_recipes_from_source_text; +use crate::recipe_adapter::compile_recipe_to_spec; +use crate::recipe_library::{ + dev_recipe_library_root, import_recipe_library, import_recipe_source, + looks_like_recipe_library_root, seed_recipe_library, select_recipe_library_root, +}; +use crate::recipe_workspace::RecipeWorkspace; + +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn temp_dir(prefix: &str) -> TempDir { + let path = std::env::temp_dir().join(format!("clawpal-{}-{}", prefix, Uuid::new_v4())); + fs::create_dir_all(&path).expect("create temp dir"); + TempDir(path) +} + +fn write_recipe(dir: &Path, name: &str, source: &str) { + let recipe_dir = dir.join(name); + fs::create_dir_all(&recipe_dir).expect("create recipe dir"); + fs::write(recipe_dir.join("recipe.json"), source).expect("write recipe"); +} + +fn write_recipe_source_file(path: &Path, source: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent"); + } + fs::write(path, source).expect("write recipe source file"); +} + +#[test] +fn import_recipe_library_compiles_preset_assets_into_workspace_recipe() { + let library_root = temp_dir("recipe-library"); + let workspace_root = temp_dir("recipe-workspace"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + + write_recipe( + library_root.path(), + "dedicated-channel-agent", + r#"{ + "id": "dedicated-channel-agent", + "name": "Dedicated Channel Agent", + "description": "Create a dedicated agent and bind it to a channel", + "version": "1.0.0", + "tags": ["discord", "agent"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent ID", "type": "string", "required": true }, + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true } + ], + "steps": [ + { "action": "create_agent", "label": "Create agent", "args": { "agentId": "{{agent_id}}", "independent": true } }, + { "action": "bind_channel", "label": "Bind channel", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "agentId": "{{agent_id}}" } } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["agent.manage", "binding.manage"] }, + "resources": { "supportedKinds": ["agent", "channel"] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "dedicated-channel-agent" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": ["agent.manage", "binding.manage"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { "kind": "create_agent", "name": "Create agent", "args": { "agentId": "{{agent_id}}", "independent": true } }, + { "kind": "bind_channel", "name": "Bind channel", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "agentId": "{{agent_id}}" } } + ], + "outputs": [] + } + }"#, + ); + + let persona_dir = library_root + .path() + .join("agent-persona-pack") + .join("assets") + .join("personas"); + fs::create_dir_all(&persona_dir).expect("create persona asset dir"); + fs::write( + persona_dir.join("friendly.md"), + "You are warm, concise, and practical.\n", + ) + .expect("write asset"); + + write_recipe( + library_root.path(), + "agent-persona-pack", + r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Import persona presets into an existing agent", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "presentation": { + "resultSummary": "Updated persona for agent {{agent_id}}" + }, + "params": [ + { "id": "agent_id", "label": "Agent", "type": "agent", "required": true }, + { "id": "persona_preset", "label": "Persona preset", "type": "string", "required": true } + ], + "steps": [ + { + "action": "setup_identity", + "label": "Apply persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["agent.identity.write"] }, + "resources": { "supportedKinds": ["agent"] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "agent-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": ["agent.identity.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "setup_identity", + "name": "Apply persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "outputs": [] + }, + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "friendly", "label": "Friendly", "asset": "assets/personas/friendly.md" } + ] + } + } + }"#, + ); + + let result = + import_recipe_library(library_root.path(), &workspace).expect("import recipe library"); + + assert_eq!(result.imported.len(), 2); + assert!(result.skipped.is_empty()); + + let imported = workspace + .read_recipe_source("agent-persona-pack") + .expect("read imported recipe"); + let imported_json: Value = serde_json::from_str(&imported).expect("parse imported recipe"); + + let params = imported_json + .get("params") + .and_then(Value::as_array) + .expect("params"); + let persona_param = params + .iter() + .find(|param| param.get("id").and_then(Value::as_str) == Some("persona_preset")) + .expect("persona_preset param"); + let options = persona_param + .get("options") + .and_then(Value::as_array) + .expect("persona options"); + assert_eq!(options.len(), 1); + assert_eq!( + options[0].get("value").and_then(Value::as_str), + Some("friendly") + ); + assert_eq!( + options[0].get("label").and_then(Value::as_str), + Some("Friendly") + ); + + let persona_map = imported_json + .pointer("/clawpalPresetMaps/persona_preset") + .and_then(Value::as_object) + .expect("persona preset map"); + assert_eq!( + persona_map.get("friendly").and_then(Value::as_str), + Some("You are warm, concise, and practical.\n") + ); + assert!(imported_json.get("clawpalImport").is_none()); + assert_eq!( + imported_json + .pointer("/presentation/resultSummary") + .and_then(Value::as_str), + Some("Updated persona for agent {{agent_id}}") + ); + + let imported_recipe = load_recipes_from_source_text(&imported) + .expect("load imported recipe") + .into_iter() + .next() + .expect("first recipe"); + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("lobster".into())); + params.insert("persona_preset".into(), Value::String("friendly".into())); + let spec = compile_recipe_to_spec(&imported_recipe, ¶ms).expect("compile imported recipe"); + + assert_eq!( + spec.actions[0].args.get("persona").and_then(Value::as_str), + Some("You are warm, concise, and practical.\n") + ); +} + +#[test] +fn import_recipe_source_reports_conflicts_without_overwriting_workspace_recipe() { + let source_root = temp_dir("recipe-source-file"); + let workspace_root = temp_dir("recipe-import-workspace"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + let source_path = source_root.path().join("recipes.json"); + + workspace + .save_recipe_source( + "agent-persona-pack", + r#"{ + "id": "agent-persona-pack", + "name": "Existing Agent Persona Pack", + "description": "Existing workspace recipe", + "version": "1.0.0", + "tags": ["agent"], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } + }"#, + ) + .expect("save existing workspace recipe"); + + write_recipe_source_file( + &source_path, + r#"{ + "recipes": [ + { + "id": "agent-persona-pack", + "name": "Imported Agent Persona Pack", + "description": "Imported from source", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } + } + ] + }"#, + ); + + let result = import_recipe_source(source_path.to_string_lossy().as_ref(), &workspace, false) + .expect("import recipe source"); + + assert!(result.imported.is_empty()); + assert_eq!(result.conflicts.len(), 1); + assert_eq!(result.conflicts[0].slug, "agent-persona-pack"); + assert!(workspace + .read_recipe_source("agent-persona-pack") + .expect("read workspace recipe") + .contains("Existing workspace recipe")); +} + +#[test] +fn seed_recipe_library_marks_bundled_updates_but_preserves_user_edits() { + let library_root = temp_dir("bundled-seed-library"); + let workspace_root = temp_dir("bundled-seed-workspace"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + + let v1 = r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Version one", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } + }"#; + write_recipe(library_root.path(), "agent-persona-pack", v1); + seed_recipe_library(library_root.path(), &workspace).expect("seed v1"); + assert!(workspace + .read_recipe_source("agent-persona-pack") + .expect("read seeded v1") + .contains("Version one")); + + let v2 = v1.replace("Version one", "Version two"); + write_recipe(library_root.path(), "agent-persona-pack", &v2); + let result = seed_recipe_library(library_root.path(), &workspace).expect("seed v2"); + assert!(result.imported.is_empty()); + assert!(workspace + .read_recipe_source("agent-persona-pack") + .expect("read still-seeded v1") + .contains("Version one")); + + workspace + .save_recipe_source( + "agent-persona-pack", + &v1.replace("Version one", "User customized"), + ) + .expect("save user customized recipe"); + let v3 = v1.replace("Version one", "Version three"); + write_recipe(library_root.path(), "agent-persona-pack", &v3); + let result = seed_recipe_library(library_root.path(), &workspace).expect("seed v3"); + + assert!(result.imported.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert!(workspace + .read_recipe_source("agent-persona-pack") + .expect("read preserved user recipe") + .contains("User customized")); +} + +#[test] +fn select_recipe_library_root_accepts_packaged_up_examples_layout() { + let resource_root = temp_dir("recipe-library-resource-root"); + let packaged_root = resource_root + .path() + .join("_up_") + .join("examples") + .join("recipe-library"); + write_recipe( + &packaged_root, + "agent-persona-pack", + r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Packaged test recipe", + "version": "1.0.0", + "tags": ["agent"], + "difficulty": "easy", + "params": [], + "steps": [] + }"#, + ); + + let resolved = select_recipe_library_root(vec![ + resource_root.path().join("recipe-library"), + resource_root.path().join("examples").join("recipe-library"), + resource_root + .path() + .join("_up_") + .join("examples") + .join("recipe-library"), + ]) + .expect("resolve packaged recipe library"); + + assert_eq!(resolved, packaged_root); + assert!(looks_like_recipe_library_root(&resolved)); +} + +#[test] +fn select_recipe_library_root_reports_checked_candidates() { + let first = PathBuf::from("/tmp/missing-recipe-library"); + let second = PathBuf::from("/tmp/missing-examples-recipe-library"); + + let error = select_recipe_library_root(vec![first.clone(), second.clone()]) + .expect_err("missing candidates should fail"); + + assert!(error.contains("bundled recipe library resource not found")); + assert!(error.contains(first.to_string_lossy().as_ref())); + assert!(error.contains(second.to_string_lossy().as_ref())); +} + +#[test] +fn dev_recipe_library_root_points_to_repo_examples() { + let root = dev_recipe_library_root(); + assert!(looks_like_recipe_library_root(&root)); +} + +#[test] +fn import_recipe_library_skips_recipe_when_asset_is_missing() { + let library_root = temp_dir("recipe-library-missing-asset"); + let workspace_root = temp_dir("recipe-workspace-missing-asset"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + + write_recipe( + library_root.path(), + "channel-persona-pack", + r#"{ + "id": "channel-persona-pack", + "name": "Channel Persona Pack", + "description": "Import persona presets into a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona"], + "difficulty": "easy", + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "persona_preset", "label": "Persona preset", "type": "string", "required": true } + ], + "steps": [ + { + "action": "config_patch", + "label": "Apply persona preset", + "args": { + "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["config.write"] }, + "resources": { "supportedKinds": ["file"] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "channel-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": ["config.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "config_patch", + "name": "Apply persona preset", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{presetMap:persona_preset}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [] + }, + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "ops", "label": "Ops", "asset": "assets/personas/ops.md" } + ] + } + } + }"#, + ); + + let result = + import_recipe_library(library_root.path(), &workspace).expect("import recipe library"); + + assert!(result.imported.is_empty()); + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0].reason.contains("assets/personas/ops.md")); + assert!(workspace + .list_entries() + .expect("workspace entries") + .is_empty()); +} + +#[test] +fn import_recipe_library_accepts_repo_example_library() { + let workspace_root = temp_dir("recipe-workspace-examples"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + let example_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") + .join("recipe-library"); + + let result = import_recipe_library(&example_root, &workspace).expect("import recipe library"); + + assert_eq!(result.imported.len(), 3); + assert!(result.skipped.is_empty()); + let imported_ids = result + .imported + .iter() + .map(|recipe| recipe.recipe_id.as_str()) + .collect::>(); + assert_eq!( + imported_ids, + std::collections::BTreeSet::from([ + "agent-persona-pack", + "channel-persona-pack", + "dedicated-agent", + ]) + ); + let entries = workspace.list_entries().expect("workspace entries"); + assert_eq!(entries.len(), 3); + + let dedicated_source = workspace + .read_recipe_source("dedicated-agent") + .expect("read dedicated agent recipe"); + let dedicated_json: Value = + serde_json::from_str(&dedicated_source).expect("parse dedicated agent recipe"); + let params = dedicated_json + .get("params") + .and_then(Value::as_array) + .expect("dedicated params"); + assert!(params + .iter() + .all(|param| param.get("id").and_then(Value::as_str) != Some("guild_id"))); + assert!(params + .iter() + .all(|param| param.get("id").and_then(Value::as_str) != Some("channel_id"))); + let actions = dedicated_json + .pointer("/executionSpecTemplate/actions") + .and_then(Value::as_array) + .expect("dedicated actions"); + let action_kinds = actions + .iter() + .filter_map(|action| action.get("kind").and_then(Value::as_str)) + .collect::>(); + assert_eq!( + action_kinds, + vec![ + "ensure_model_profile", + "create_agent", + "set_agent_identity", + "set_agent_persona" + ] + ); + + let persona_pack_source = workspace + .read_recipe_source("agent-persona-pack") + .expect("read agent persona pack"); + let persona_pack_json: Value = + serde_json::from_str(&persona_pack_source).expect("parse agent persona pack"); + let persona_actions = persona_pack_json + .pointer("/executionSpecTemplate/actions") + .and_then(Value::as_array) + .expect("persona pack actions"); + assert_eq!( + persona_actions + .iter() + .filter_map(|action| action.get("kind").and_then(Value::as_str)) + .collect::>(), + vec!["set_agent_persona"] + ); + + let channel_pack_source = workspace + .read_recipe_source("channel-persona-pack") + .expect("read channel persona pack"); + let channel_pack_json: Value = + serde_json::from_str(&channel_pack_source).expect("parse channel persona pack"); + let channel_actions = channel_pack_json + .pointer("/executionSpecTemplate/actions") + .and_then(Value::as_array) + .expect("channel persona actions"); + assert_eq!( + channel_actions + .iter() + .filter_map(|action| action.get("kind").and_then(Value::as_str)) + .collect::>(), + vec!["set_channel_persona"] + ); +} + +#[test] +fn import_recipe_library_skips_duplicate_slug_against_existing_workspace_recipe() { + let library_root = temp_dir("recipe-library-duplicate-slug"); + let workspace_root = temp_dir("recipe-workspace-duplicate-slug"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + + workspace + .save_recipe_source( + "agent-persona-pack", + r#"{ + "id": "agent-persona-pack", + "name": "Existing Agent Persona Pack", + "description": "Existing workspace recipe", + "version": "1.0.0", + "tags": ["agent"], + "difficulty": "easy", + "params": [], + "steps": [] + }"#, + ) + .expect("seed workspace recipe"); + + let persona_dir = library_root + .path() + .join("agent-persona-pack") + .join("assets") + .join("personas"); + fs::create_dir_all(&persona_dir).expect("create persona dir"); + fs::write( + persona_dir.join("coach.md"), + "You coach incidents calmly.\n", + ) + .expect("write asset"); + + write_recipe( + library_root.path(), + "agent-persona-pack", + r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Import persona presets into an existing agent", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent", "type": "agent", "required": true }, + { "id": "persona_preset", "label": "Persona preset", "type": "string", "required": true } + ], + "steps": [ + { + "action": "setup_identity", + "label": "Apply persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": ["agent.identity.write"] }, + "resources": { "supportedKinds": ["agent"] }, + "execution": { "supportedKinds": ["job"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { "name": "agent-persona-pack" }, + "source": {}, + "target": {}, + "execution": { "kind": "job" }, + "capabilities": { "usedCapabilities": ["agent.identity.write"] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [ + { + "kind": "setup_identity", + "name": "Apply persona preset", + "args": { + "agentId": "{{agent_id}}", + "persona": "{{presetMap:persona_preset}}" + } + } + ], + "outputs": [] + }, + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "coach", "label": "Coach", "asset": "assets/personas/coach.md" } + ] + } + } + }"#, + ); + + let result = + import_recipe_library(library_root.path(), &workspace).expect("import recipe library"); + + assert!(result.imported.is_empty()); + assert_eq!(result.skipped.len(), 1); + assert!(result.skipped[0] + .reason + .contains("duplicate recipe slug 'agent-persona-pack'")); +} + +#[test] +fn seed_recipe_library_imports_repo_example_library_into_empty_workspace() { + let workspace_root = temp_dir("recipe-workspace-seed-examples"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + let example_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") + .join("recipe-library"); + + let result = seed_recipe_library(&example_root, &workspace).expect("seed recipe library"); + + assert_eq!(result.imported.len(), 3); + assert!(result.skipped.is_empty()); + assert!(result.warnings.is_empty()); + assert_eq!( + workspace.list_entries().expect("workspace entries").len(), + 3 + ); +} + +#[test] +fn seed_recipe_library_preserves_existing_workspace_recipe() { + let workspace_root = temp_dir("recipe-workspace-seed-existing"); + let workspace = RecipeWorkspace::new(workspace_root.path().to_path_buf()); + let example_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") + .join("recipe-library"); + + let original_source = r#"{ + "id": "agent-persona-pack", + "name": "Custom Agent Persona Pack", + "description": "User-edited recipe", + "version": "1.0.0", + "tags": ["custom"], + "difficulty": "easy", + "params": [], + "steps": [] + }"#; + + workspace + .save_recipe_source("agent-persona-pack", original_source) + .expect("seed custom workspace recipe"); + + let result = seed_recipe_library(&example_root, &workspace).expect("seed recipe library"); + + assert_eq!(result.imported.len(), 2); + assert!(result.skipped.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert!(result.warnings[0].contains("agent-persona-pack")); + assert_eq!( + serde_json::from_str::( + &workspace + .read_recipe_source("agent-persona-pack") + .expect("read preserved recipe") + ) + .expect("parse preserved recipe"), + serde_json::from_str::(original_source).expect("parse original recipe") + ); +} diff --git a/src-tauri/src/recipe_planner.rs b/src-tauri/src/recipe_planner.rs new file mode 100644 index 00000000..c58a23bb --- /dev/null +++ b/src-tauri/src/recipe_planner.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use uuid::Uuid; + +use crate::execution_spec::{ExecutionResourceClaim, ExecutionSpec}; +use crate::recipe::{load_recipes_from_source_text, step_references_empty_param, Recipe}; +use crate::recipe_adapter::compile_recipe_to_spec; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipePlanSummary { + pub recipe_id: String, + pub recipe_name: String, + pub execution_kind: String, + pub action_count: usize, + pub skipped_step_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RecipePlan { + pub summary: RecipePlanSummary, + pub used_capabilities: Vec, + pub concrete_claims: Vec, + pub execution_spec_digest: String, + pub execution_spec: ExecutionSpec, + pub warnings: Vec, +} + +pub fn build_recipe_plan( + recipe: &Recipe, + params: &Map, +) -> Result { + let execution_spec = compile_recipe_to_spec(recipe, params)?; + let skipped_step_count = recipe + .steps + .iter() + .filter(|step| step_references_empty_param(step, params)) + .count(); + + let mut warnings = Vec::new(); + if skipped_step_count > 0 { + warnings.push(format!( + "{} optional step(s) will be skipped because their parameters are empty.", + skipped_step_count + )); + } + let digest_source = serde_json::to_vec(&execution_spec).map_err(|error| error.to_string())?; + let execution_spec_digest = Uuid::new_v5(&Uuid::NAMESPACE_OID, &digest_source).to_string(); + + Ok(RecipePlan { + summary: RecipePlanSummary { + recipe_id: recipe.id.clone(), + recipe_name: recipe.name.clone(), + execution_kind: execution_spec.execution.kind.clone(), + action_count: execution_spec.actions.len(), + skipped_step_count, + }, + used_capabilities: execution_spec.capabilities.used_capabilities.clone(), + concrete_claims: execution_spec.resources.claims.clone(), + execution_spec_digest, + execution_spec, + warnings, + }) +} + +pub fn build_recipe_plan_from_source_text( + recipe_id: &str, + params: &Map, + source_text: &str, +) -> Result { + let recipe = load_recipes_from_source_text(source_text)? + .into_iter() + .find(|recipe| recipe.id == recipe_id) + .ok_or_else(|| format!("recipe not found: {}", recipe_id))?; + build_recipe_plan(&recipe, params) +} diff --git a/src-tauri/src/recipe_planner_tests.rs b/src-tauri/src/recipe_planner_tests.rs new file mode 100644 index 00000000..aacd8602 --- /dev/null +++ b/src-tauri/src/recipe_planner_tests.rs @@ -0,0 +1,302 @@ +use serde_json::{Map, Value}; + +use crate::recipe::{load_recipes_from_source_text, Recipe}; +use crate::recipe_adapter::export_recipe_source; +use crate::recipe_planner::{build_recipe_plan, build_recipe_plan_from_source_text}; + +const TEST_RECIPES_SOURCE: &str = r#"{ + "recipes": [ + { + "id": "dedicated-channel-agent", + "name": "Create dedicated Agent for Channel", + "description": "Create an agent and bind it to a Discord channel", + "version": "1.0.0", + "tags": ["discord", "agent", "persona"], + "difficulty": "easy", + "params": [ + { "id": "agent_id", "label": "Agent ID", "type": "string", "required": true, "placeholder": "e.g. my-bot" }, + { "id": "model", "label": "Model", "type": "model_profile", "required": true, "defaultValue": "__default__" }, + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "independent", "label": "Create independent agent", "type": "boolean", "required": false }, + { "id": "name", "label": "Display Name", "type": "string", "required": false, "dependsOn": "independent" }, + { "id": "emoji", "label": "Emoji", "type": "string", "required": false, "dependsOn": "independent" }, + { "id": "persona", "label": "Persona", "type": "textarea", "required": false, "dependsOn": "independent" } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "dedicated-channel-agent", + "version": "1.0.0", + "description": "Create an agent and bind it to a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["agent.manage", "agent.identity.write", "binding.manage", "config.write"] + }, + "resources": { + "supportedKinds": ["agent", "channel", "file"] + }, + "execution": { + "supportedKinds": ["job"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "dedicated-channel-agent" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "job" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 4 + }, + "actions": [ + { + "kind": "create_agent", + "name": "Create agent", + "args": { + "agentId": "{{agent_id}}", + "modelProfileId": "{{model}}", + "independent": "{{independent}}" + } + }, + { + "kind": "setup_identity", + "name": "Set agent identity", + "args": { + "agentId": "{{agent_id}}", + "name": "{{name}}", + "emoji": "{{emoji}}" + } + }, + { + "kind": "bind_channel", + "name": "Bind channel to agent", + "args": { + "channelType": "discord", + "peerId": "{{channel_id}}", + "agentId": "{{agent_id}}" + } + }, + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "dedicated-channel-agent" }] + }, + "steps": [ + { "action": "create_agent", "label": "Create agent", "args": { "agentId": "{{agent_id}}", "modelProfileId": "{{model}}", "independent": "{{independent}}" } }, + { "action": "setup_identity", "label": "Set agent identity", "args": { "agentId": "{{agent_id}}", "name": "{{name}}", "emoji": "{{emoji}}" } }, + { "action": "bind_channel", "label": "Bind channel to agent", "args": { "channelType": "discord", "peerId": "{{channel_id}}", "agentId": "{{agent_id}}" } }, + { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } + ] + }, + { + "id": "discord-channel-persona", + "name": "Channel Persona", + "description": "Set a custom persona for a Discord channel", + "version": "1.0.0", + "tags": ["discord", "persona", "beginner"], + "difficulty": "easy", + "params": [ + { "id": "guild_id", "label": "Guild", "type": "discord_guild", "required": true }, + { "id": "channel_id", "label": "Channel", "type": "discord_channel", "required": true }, + { "id": "persona", "label": "Persona", "type": "textarea", "required": true, "placeholder": "You are..." } + ], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": { + "name": "discord-channel-persona", + "version": "1.0.0", + "description": "Set a custom persona for a Discord channel" + }, + "compatibility": {}, + "inputs": [], + "capabilities": { + "allowed": ["config.write"] + }, + "resources": { + "supportedKinds": ["file"] + }, + "execution": { + "supportedKinds": ["attachment"] + }, + "runner": {}, + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": { + "name": "discord-channel-persona" + }, + "source": {}, + "target": {}, + "execution": { + "kind": "attachment" + }, + "capabilities": { + "usedCapabilities": [] + }, + "resources": { + "claims": [] + }, + "secrets": { + "bindings": [] + }, + "desiredState": { + "actionCount": 1 + }, + "actions": [ + { + "kind": "config_patch", + "name": "Set channel persona", + "args": { + "patch": { + "channels": { + "discord": { + "guilds": { + "{{guild_id}}": { + "channels": { + "{{channel_id}}": { + "systemPrompt": "{{persona}}" + } + } + } + } + } + } + } + } + } + ], + "outputs": [{ "kind": "recipe-summary", "recipeId": "discord-channel-persona" }] + }, + "steps": [ + { "action": "config_patch", "label": "Set channel persona", "args": { "patchTemplate": "{\"channels\":{\"discord\":{\"guilds\":{\"{{guild_id}}\":{\"channels\":{\"{{channel_id}}\":{\"systemPrompt\":\"{{persona}}\"}}}}}}}" } } + ] + } + ] +}"#; + +fn test_recipe(id: &str) -> Recipe { + load_recipes_from_source_text(TEST_RECIPES_SOURCE) + .expect("parse test recipe source") + .into_iter() + .find(|recipe| recipe.id == id) + .expect("test recipe") +} + +fn sample_inputs() -> Map { + let mut params = Map::new(); + params.insert("guild_id".into(), Value::String("guild-1".into())); + params.insert("channel_id".into(), Value::String("channel-1".into())); + params.insert( + "persona".into(), + Value::String("Keep answers concise".into()), + ); + params +} + +#[test] +fn plan_recipe_returns_capabilities_claims_and_digest() { + let recipe = test_recipe("discord-channel-persona"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert!(!plan.used_capabilities.is_empty()); + assert!(!plan.concrete_claims.is_empty()); + assert!(!plan.execution_spec_digest.is_empty()); +} + +#[test] +fn plan_recipe_includes_execution_spec_for_executor_bridge() { + let recipe = test_recipe("discord-channel-persona"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert_eq!(plan.execution_spec.kind, "ExecutionSpec"); + assert!(!plan.execution_spec.actions.is_empty()); +} + +#[test] +fn plan_recipe_does_not_emit_legacy_bridge_warning() { + let recipe = test_recipe("discord-channel-persona"); + + let plan = build_recipe_plan(&recipe, &sample_inputs()).expect("build plan"); + + assert!(plan + .warnings + .iter() + .all(|warning| !warning.to_ascii_lowercase().contains("legacy"))); +} + +#[test] +fn plan_recipe_skips_optional_steps_from_structured_template() { + let recipe = test_recipe("dedicated-channel-agent"); + let mut params = sample_inputs(); + params.insert("agent_id".into(), Value::String("bot-alpha".into())); + params.insert("model".into(), Value::String("__default__".into())); + params.insert("independent".into(), Value::String("true".into())); + params.insert("name".into(), Value::String(String::new())); + params.insert("emoji".into(), Value::String(String::new())); + params.insert("persona".into(), Value::String(String::new())); + + let plan = build_recipe_plan(&recipe, ¶ms).expect("build plan"); + + assert_eq!(plan.summary.skipped_step_count, 2); + assert_eq!(plan.summary.action_count, 2); + assert_eq!(plan.execution_spec.actions.len(), 2); +} + +#[test] +fn plan_recipe_source_uses_unsaved_draft_text() { + let recipe = test_recipe("discord-channel-persona"); + let source = export_recipe_source(&recipe).expect("export source"); + let recipes = load_recipes_from_source_text(&source).expect("parse source"); + + let plan = + build_recipe_plan_from_source_text("discord-channel-persona", &sample_inputs(), &source) + .expect("build plan from source"); + + assert_eq!(recipes.len(), 1); + assert_eq!(plan.summary.recipe_id, "discord-channel-persona"); + assert_eq!(plan.execution_spec.kind, "ExecutionSpec"); +} diff --git a/src-tauri/src/recipe_runtime/mod.rs b/src-tauri/src/recipe_runtime/mod.rs new file mode 100644 index 00000000..ef587f6d --- /dev/null +++ b/src-tauri/src/recipe_runtime/mod.rs @@ -0,0 +1 @@ +pub mod systemd; diff --git a/src-tauri/src/recipe_runtime/systemd.rs b/src-tauri/src/recipe_runtime/systemd.rs new file mode 100644 index 00000000..ea96a33d --- /dev/null +++ b/src-tauri/src/recipe_runtime/systemd.rs @@ -0,0 +1,420 @@ +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::execution_spec::ExecutionSpec; + +#[derive(Debug, Clone, Default)] +pub struct SystemdRuntimePlan { + pub unit_name: String, + pub commands: Vec>, + pub resources: Vec, + pub warnings: Vec, +} + +pub fn materialize_job(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = job_unit_name(spec); + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command(&unit_name, &command, None)], + resources: collect_resource_refs(spec), + warnings: Vec::new(), + }) +} + +pub fn materialize_service(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = service_unit_name(spec); + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command( + &unit_name, + &command, + Some(&["--property=Restart=always", "--property=RestartSec=5s"]), + )], + resources: collect_resource_refs(spec), + warnings: Vec::new(), + }) +} + +pub fn materialize_schedule(spec: &ExecutionSpec) -> Result { + let command = extract_command(spec)?; + let unit_name = job_unit_name(spec); + let on_calendar = extract_schedule(spec) + .as_deref() + .ok_or_else(|| "schedule spec is missing desired_state.schedule.onCalendar".to_string())? + .to_string(); + + let mut resources = collect_resource_refs(spec); + let launch_ref = format!("job/{}", sanitize_unit_fragment(spec_name(spec))); + if !resources.iter().any(|resource| resource == &launch_ref) { + resources.push(launch_ref); + } + + Ok(SystemdRuntimePlan { + unit_name: unit_name.clone(), + commands: vec![build_systemd_run_command( + &unit_name, + &command, + Some(&[ + "--timer-property=Persistent=true", + &format!("--on-calendar={}", on_calendar), + ]), + )], + resources, + warnings: Vec::new(), + }) +} + +pub fn materialize_attachment(spec: &ExecutionSpec) -> Result { + let unit_name = attachment_unit_name(spec); + let mut commands = Vec::new(); + let mut warnings = Vec::new(); + let mut needs_daemon_reload = false; + + if let Some(drop_in) = spec + .desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + { + let target = drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let name = drop_in + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let content = extract_drop_in_content(drop_in); + let missing_target = target.is_none(); + let missing_name = name.is_none(); + let missing_content = content.is_none(); + + match (target, name, content) { + (Some(target), Some(name), Some(content)) => { + commands.push(vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + target.to_string(), + name.to_string(), + content, + ]); + needs_daemon_reload = true; + } + _ => { + let mut missing = Vec::new(); + if missing_target { + missing.push("unit/target"); + } + if missing_name { + missing.push("name"); + } + if missing_content { + missing.push("content"); + } + warnings.push(format!( + "attachment systemdDropIn is missing {}", + missing.join(", ") + )); + } + } + } + + match ( + attachment_target_unit(spec), + render_env_patch_dropin_content(spec), + ) { + (Some(target), Some(content)) => { + commands.push(vec![ + crate::commands::INTERNAL_SYSTEMD_DROPIN_WRITE_COMMAND.into(), + target, + env_patch_dropin_name(spec), + content, + ]); + needs_daemon_reload = true; + } + (None, Some(_)) => warnings.push( + "attachment envPatch is missing a target unit in systemdDropIn.unit/target or service claim target" + .into(), + ), + _ => {} + } + + if needs_daemon_reload { + commands.push(vec![ + "systemctl".into(), + "--user".into(), + "daemon-reload".into(), + ]); + } + + if commands.is_empty() { + warnings.push( + "attachment spec materialized without concrete systemdDropIn/envPatch operations" + .into(), + ); + } + + Ok(SystemdRuntimePlan { + unit_name, + commands, + resources: collect_resource_refs(spec), + warnings, + }) +} + +fn extract_drop_in_content(drop_in: &serde_json::Map) -> Option { + ["content", "contents", "text", "body"] + .iter() + .find_map(|key| { + drop_in + .get(*key) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .filter(|value| !value.trim().is_empty()) + }) +} + +pub fn attachment_target_unit(spec: &ExecutionSpec) -> Option { + spec.desired_state + .get("systemdDropIn") + .and_then(Value::as_object) + .and_then(|drop_in| { + drop_in + .get("unit") + .or_else(|| drop_in.get("target")) + .and_then(Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .or_else(|| { + spec.resources + .claims + .iter() + .find(|claim| claim.kind == "service") + .and_then(|claim| claim.target.as_deref().or(claim.id.as_deref())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + }) +} + +pub fn env_patch_dropin_name(spec: &ExecutionSpec) -> String { + format!( + "90-clawpal-env-{}.conf", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +pub fn env_patch_dropin_path(spec: &ExecutionSpec) -> Option { + attachment_target_unit(spec).map(|target| { + format!( + "~/.config/systemd/user/{}.d/{}", + target, + env_patch_dropin_name(spec) + ) + }) +} + +pub fn render_env_patch_dropin_content(spec: &ExecutionSpec) -> Option { + let patch = spec + .desired_state + .get("envPatch") + .and_then(Value::as_object)?; + let mut values = BTreeMap::new(); + + for (key, value) in patch { + let trimmed_key = key.trim(); + if trimmed_key.is_empty() { + continue; + } + let rendered = match value { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => flag.to_string(), + Value::Null => String::new(), + _ => continue, + }; + values.insert(trimmed_key.to_string(), rendered); + } + + if values.is_empty() { + return None; + } + + let mut content = String::from("[Service]\n"); + for (key, value) in values { + content.push_str("Environment=\""); + content.push_str(&escape_systemd_environment_assignment(&key, &value)); + content.push_str("\"\n"); + } + Some(content) +} + +fn escape_systemd_environment_assignment(key: &str, value: &str) -> String { + format!( + "{}={}", + key, + value.replace('\\', "\\\\").replace('"', "\\\"") + ) +} + +fn build_systemd_run_command( + unit_name: &str, + command: &[String], + extra_flags: Option<&[&str]>, +) -> Vec { + let mut cmd = vec![ + "systemd-run".into(), + format!("--unit={}", unit_name), + "--collect".into(), + "--service-type=exec".into(), + ]; + if let Some(flags) = extra_flags { + cmd.extend(flags.iter().map(|flag| flag.to_string())); + } + cmd.push("--".into()); + cmd.extend(command.iter().cloned()); + cmd +} + +fn collect_resource_refs(spec: &ExecutionSpec) -> Vec { + let mut resources = Vec::new(); + + for claim in &spec.resources.claims { + if let Some(id) = &claim.id { + push_unique(&mut resources, id.clone()); + } + if let Some(target) = &claim.target { + push_unique(&mut resources, target.clone()); + } + if let Some(path) = &claim.path { + push_unique(&mut resources, path.clone()); + } + } + + if let Some(schedule_id) = spec + .desired_state + .get("schedule") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + { + push_unique(&mut resources, schedule_id.to_string()); + } + + resources +} + +fn extract_command(spec: &ExecutionSpec) -> Result, String> { + if let Some(command) = extract_command_from_value(spec.desired_state.get("command")) { + return Ok(command); + } + if let Some(command) = spec + .desired_state + .get("job") + .and_then(|value| value.get("command")) + .and_then(|value| extract_command_from_value(Some(value))) + { + return Ok(command); + } + for action in &spec.actions { + if let Some(command) = action + .args + .get("command") + .and_then(|value| extract_command_from_value(Some(value))) + { + return Ok(command); + } + } + + Err("execution spec is missing a concrete command payload".into()) +} + +fn extract_command_from_value(value: Option<&Value>) -> Option> { + value + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.as_str().map(|text| text.to_string())) + .collect::>() + }) + .filter(|parts| !parts.is_empty()) +} + +fn extract_schedule(spec: &ExecutionSpec) -> Option { + spec.desired_state + .get("schedule") + .and_then(|value| value.get("onCalendar")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + .or_else(|| { + spec.actions.iter().find_map(|action| { + action + .args + .get("onCalendar") + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + }) +} + +fn job_unit_name(spec: &ExecutionSpec) -> String { + format!("clawpal-job-{}", sanitize_unit_fragment(spec_name(spec))) +} + +fn service_unit_name(spec: &ExecutionSpec) -> String { + format!( + "clawpal-service-{}", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +fn attachment_unit_name(spec: &ExecutionSpec) -> String { + format!( + "clawpal-attachment-{}", + sanitize_unit_fragment(spec_name(spec)) + ) +} + +fn spec_name(spec: &ExecutionSpec) -> &str { + spec.metadata + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .unwrap_or("spec") +} + +fn sanitize_unit_fragment(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let collapsed = sanitized + .split('-') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + "spec".into() + } else { + collapsed + } +} + +fn push_unique(values: &mut Vec, next: String) { + if !values.iter().any(|existing| existing == &next) { + values.push(next); + } +} diff --git a/src-tauri/src/recipe_source_tests.rs b/src-tauri/src/recipe_source_tests.rs new file mode 100644 index 00000000..52921e38 --- /dev/null +++ b/src-tauri/src/recipe_source_tests.rs @@ -0,0 +1,129 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use uuid::Uuid; + +use crate::recipe::{find_recipe_with_source, load_recipes_from_source}; + +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn temp_dir(prefix: &str) -> TempDir { + let path = std::env::temp_dir().join(format!("clawpal-{}-{}", prefix, Uuid::new_v4())); + fs::create_dir_all(&path).expect("create temp dir"); + TempDir(path) +} + +fn write_recipe_dir(path: &Path, source: &str) { + fs::create_dir_all(path).expect("create recipe dir"); + fs::write(path.join("recipe.json"), source).expect("write recipe"); +} + +#[test] +fn load_recipes_from_source_supports_single_recipe_directory() { + let recipe_dir = temp_dir("recipe-source-directory"); + let asset_dir = recipe_dir.path().join("assets").join("personas"); + fs::create_dir_all(&asset_dir).expect("create asset dir"); + fs::write( + asset_dir.join("friendly.md"), + "You are warm, concise, and practical.\n", + ) + .expect("write asset"); + + write_recipe_dir( + recipe_dir.path(), + r#"{ + "id": "agent-persona-pack", + "name": "Agent Persona Pack", + "description": "Apply a persona preset", + "version": "1.0.0", + "tags": ["agent", "persona"], + "difficulty": "easy", + "params": [ + { "id": "persona_preset", "label": "Persona", "type": "string", "required": true } + ], + "steps": [], + "clawpalImport": { + "presetParams": { + "persona_preset": [ + { "value": "friendly", "label": "Friendly", "asset": "assets/personas/friendly.md" } + ] + } + } + }"#, + ); + + let recipes = load_recipes_from_source(recipe_dir.path().to_string_lossy().as_ref()) + .expect("load recipe directory"); + + assert_eq!(recipes.len(), 1); + assert_eq!(recipes[0].id, "agent-persona-pack"); + assert_eq!( + recipes[0] + .params + .first() + .and_then(|param| param.options.as_ref()) + .and_then(|options| options.first()) + .map(|option| option.value.as_str()), + Some("friendly") + ); + assert_eq!( + recipes[0] + .clawpal_preset_maps + .as_ref() + .and_then(|maps| maps.get("persona_preset")) + .and_then(|value| value.get("friendly")) + .and_then(|value| value.as_str()), + Some("You are warm, concise, and practical.\n") + ); +} + +#[test] +fn find_recipe_with_source_supports_single_recipe_directory() { + let recipe_dir = temp_dir("recipe-find-directory"); + write_recipe_dir( + recipe_dir.path(), + r#"{ + "id": "directory-only-recipe", + "name": "Directory Only Recipe", + "description": "Loaded from a recipe directory", + "version": "1.0.0", + "tags": ["directory"], + "difficulty": "easy", + "params": [], + "steps": [] + }"#, + ); + + let recipe = find_recipe_with_source( + "directory-only-recipe", + Some(recipe_dir.path().to_string_lossy().to_string()), + ) + .expect("find recipe from directory source"); + + assert_eq!(recipe.name, "Directory Only Recipe"); +} + +#[test] +fn load_recipes_from_source_rejects_recipe_directory_without_recipe_json() { + let recipe_dir = temp_dir("recipe-source-missing-json"); + + let error = load_recipes_from_source(recipe_dir.path().to_string_lossy().as_ref()) + .expect_err("directory without recipe.json should fail"); + + assert!( + error.contains("recipe.json not found"), + "unexpected error: {error}" + ); +} diff --git a/src-tauri/src/recipe_store.rs b/src-tauri/src/recipe_store.rs new file mode 100644 index 00000000..2cb05336 --- /dev/null +++ b/src-tauri/src/recipe_store.rs @@ -0,0 +1,225 @@ +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::models::resolve_paths; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ResourceClaim { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Artifact { + pub id: String, + pub kind: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Run { + pub id: String, + pub instance_id: String, + pub recipe_id: String, + pub execution_kind: String, + pub runner: String, + pub status: String, + pub summary: String, + pub started_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(default)] + pub artifacts: Vec, + #[serde(default)] + pub resource_claims: Vec, + #[serde(default)] + pub warnings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_origin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecipeInstance { + pub id: String, + pub recipe_id: String, + pub execution_kind: String, + pub runner: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_run_id: Option, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct RecipeRuntimeIndex { + #[serde(default)] + instances: Vec, + #[serde(default)] + runs: Vec, +} + +#[derive(Debug, Clone)] +pub struct RecipeStore { + runtime_dir: PathBuf, + index_path: PathBuf, +} + +impl RecipeStore { + pub fn new(runtime_dir: PathBuf) -> Self { + Self { + index_path: runtime_dir.join("index.json"), + runtime_dir, + } + } + + pub fn from_resolved_paths() -> Self { + Self::new(resolve_paths().recipe_runtime_dir) + } + + pub fn for_test() -> Self { + let root = std::env::temp_dir().join(format!("clawpal-recipe-store-{}", Uuid::new_v4())); + Self::new(root) + } + + pub fn record_run(&self, run: Run) -> Result { + fs::create_dir_all(&self.runtime_dir).map_err(|error| error.to_string())?; + + let mut index = self.read_index()?; + index.runs.retain(|existing| existing.id != run.id); + index.runs.push(run.clone()); + sort_runs(&mut index.runs); + index.instances = build_instances(&index.runs); + + self.write_index(&index)?; + Ok(run) + } + + pub fn list_runs(&self, instance_id: &str) -> Result, String> { + let index = self.read_index()?; + Ok(index + .runs + .into_iter() + .filter(|run| run.instance_id == instance_id) + .collect()) + } + + pub fn list_all_runs(&self) -> Result, String> { + Ok(self.read_index()?.runs) + } + + pub fn list_instances(&self) -> Result, String> { + Ok(self.read_index()?.instances) + } + + pub fn delete_runs(&self, instance_id: Option<&str>) -> Result { + let mut index = self.read_index()?; + let before = index.runs.len(); + index.runs.retain(|run| match instance_id { + Some(instance_id) => run.instance_id != instance_id, + None => false, + }); + let deleted = before.saturating_sub(index.runs.len()); + if deleted == 0 { + return Ok(0); + } + sort_runs(&mut index.runs); + index.instances = build_instances(&index.runs); + self.write_index(&index)?; + Ok(deleted) + } + + fn read_index(&self) -> Result { + if !self.index_path.exists() { + return Ok(RecipeRuntimeIndex::default()); + } + + let mut file = File::open(&self.index_path).map_err(|error| error.to_string())?; + let mut text = String::new(); + file.read_to_string(&mut text) + .map_err(|error| error.to_string())?; + + if text.trim().is_empty() { + return Ok(RecipeRuntimeIndex::default()); + } + + serde_json::from_str(&text).map_err(|error| error.to_string()) + } + + fn write_index(&self, index: &RecipeRuntimeIndex) -> Result<(), String> { + fs::create_dir_all(&self.runtime_dir).map_err(|error| error.to_string())?; + let text = serde_json::to_string_pretty(index).map_err(|error| error.to_string())?; + atomic_write(&self.index_path, &text) + } +} + +fn sort_runs(runs: &mut Vec) { + runs.sort_by(|left, right| { + right + .started_at + .cmp(&left.started_at) + .then_with(|| right.id.cmp(&left.id)) + }); +} + +fn build_instances(runs: &[Run]) -> Vec { + let mut instances = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + + for run in runs { + if !seen.insert(run.instance_id.clone()) { + continue; + } + let updated_at = run + .finished_at + .clone() + .unwrap_or_else(|| run.started_at.clone()); + instances.push(RecipeInstance { + id: run.instance_id.clone(), + recipe_id: run.recipe_id.clone(), + execution_kind: run.execution_kind.clone(), + runner: run.runner.clone(), + status: run.status.clone(), + last_run_id: Some(run.id.clone()), + updated_at, + }); + } + + instances.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.id.cmp(&right.id)) + }); + instances +} + +fn atomic_write(path: &Path, text: &str) -> Result<(), String> { + let tmp_path = path.with_extension("tmp"); + { + let mut file = File::create(&tmp_path).map_err(|error| error.to_string())?; + file.write_all(text.as_bytes()) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + } + fs::rename(&tmp_path, path).map_err(|error| error.to_string()) +} diff --git a/src-tauri/src/recipe_store_tests.rs b/src-tauri/src/recipe_store_tests.rs new file mode 100644 index 00000000..d0d423b7 --- /dev/null +++ b/src-tauri/src/recipe_store_tests.rs @@ -0,0 +1,134 @@ +use crate::recipe_store::{Artifact, RecipeStore, ResourceClaim, Run}; + +fn sample_run() -> Run { + Run { + id: "run_01".into(), + instance_id: "inst_01".into(), + recipe_id: "discord-channel-persona".into(), + execution_kind: "attachment".into(), + runner: "local".into(), + status: "succeeded".into(), + summary: "Applied persona patch".into(), + started_at: "2026-03-11T10:00:00Z".into(), + finished_at: Some("2026-03-11T10:00:03Z".into()), + artifacts: vec![Artifact { + id: "artifact_01".into(), + kind: "configDiff".into(), + label: "Rendered patch".into(), + path: Some("/tmp/rendered-patch.json".into()), + }], + resource_claims: vec![ResourceClaim { + kind: "path".into(), + id: Some("openclaw.config".into()), + target: None, + path: Some("~/.openclaw/openclaw.json".into()), + }], + warnings: vec![], + source_origin: None, + source_digest: None, + workspace_path: None, + } +} + +fn sample_run_with_source() -> Run { + let mut run = sample_run(); + run.source_origin = Some("draft".into()); + run.source_digest = Some("digest-123".into()); + run.workspace_path = + Some("/Users/chen/.clawpal/recipes/workspace/channel-persona.recipe.json".into()); + run +} + +#[test] +fn record_run_persists_instance_and_artifacts() { + let store = RecipeStore::for_test(); + let run = store.record_run(sample_run()).expect("record run"); + + assert_eq!(store.list_runs("inst_01").expect("list runs")[0].id, run.id); + assert_eq!( + store.list_instances().expect("list instances")[0] + .last_run_id + .as_deref(), + Some(run.id.as_str()) + ); + assert_eq!( + store.list_runs("inst_01").expect("list runs")[0].artifacts[0].id, + "artifact_01" + ); +} + +#[test] +fn list_all_runs_returns_latest_runs() { + let store = RecipeStore::for_test(); + store.record_run(sample_run()).expect("record first run"); + + let mut second_run = sample_run(); + second_run.id = "run_02".into(); + second_run.instance_id = "ssh:prod-a".into(); + second_run.started_at = "2026-03-11T11:00:00Z".into(); + second_run.finished_at = Some("2026-03-11T11:00:05Z".into()); + store.record_run(second_run).expect("record second run"); + + let runs = store.list_all_runs().expect("list all runs"); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].id, "run_02"); + assert_eq!(runs[1].id, "run_01"); +} + +#[test] +fn recorded_run_persists_source_digest_and_origin() { + let store = RecipeStore::for_test(); + store + .record_run(sample_run_with_source()) + .expect("record run with source"); + + let stored = store.list_runs("inst_01").expect("list runs"); + assert_eq!(stored[0].source_origin.as_deref(), Some("draft")); + assert_eq!(stored[0].source_digest.as_deref(), Some("digest-123")); + assert!(stored[0] + .workspace_path + .as_deref() + .is_some_and(|path| path.ends_with("channel-persona.recipe.json"))); +} + +#[test] +fn delete_runs_for_instance_removes_runs_and_rebuilds_instances() { + let store = RecipeStore::for_test(); + store.record_run(sample_run()).expect("record first run"); + + let mut second_run = sample_run(); + second_run.id = "run_02".into(); + second_run.instance_id = "ssh:prod-a".into(); + second_run.started_at = "2026-03-11T11:00:00Z".into(); + second_run.finished_at = Some("2026-03-11T11:00:05Z".into()); + store.record_run(second_run).expect("record second run"); + + let deleted = store + .delete_runs(Some("inst_01")) + .expect("delete instance runs"); + + assert_eq!(deleted, 1); + assert!(store + .list_runs("inst_01") + .expect("list removed runs") + .is_empty()); + let remaining_runs = store.list_all_runs().expect("list all runs"); + assert_eq!(remaining_runs.len(), 1); + assert_eq!(remaining_runs[0].instance_id, "ssh:prod-a"); + let instances = store.list_instances().expect("list instances"); + assert_eq!(instances.len(), 1); + assert_eq!(instances[0].id, "ssh:prod-a"); + assert_eq!(instances[0].last_run_id.as_deref(), Some("run_02")); +} + +#[test] +fn delete_runs_without_scope_clears_all_runs_and_instances() { + let store = RecipeStore::for_test(); + store.record_run(sample_run()).expect("record first run"); + + let deleted = store.delete_runs(None).expect("delete all runs"); + + assert_eq!(deleted, 1); + assert!(store.list_all_runs().expect("list all runs").is_empty()); + assert!(store.list_instances().expect("list instances").is_empty()); +} diff --git a/src-tauri/src/recipe_workspace.rs b/src-tauri/src/recipe_workspace.rs new file mode 100644 index 00000000..4d9cc360 --- /dev/null +++ b/src-tauri/src/recipe_workspace.rs @@ -0,0 +1,613 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config_io::write_text; +use crate::models::resolve_paths; +use crate::recipe::load_recipes_from_source_text; +use crate::recipe_library::RecipeLibraryImportResult; + +const WORKSPACE_FILE_SUFFIX: &str = ".recipe.json"; +const WORKSPACE_INDEX_FILE: &str = ".bundled-seed-index.json"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RecipeWorkspaceSourceKind { + Bundled, + LocalImport, + RemoteUrl, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum BundledRecipeState { + Missing, + UpToDate, + UpdateAvailable, + LocalModified, + ConflictedUpdate, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RecipeTrustLevel { + Trusted, + Caution, + Untrusted, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RecipeRiskLevel { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeWorkspaceEntry { + pub slug: String, + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub recipe_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundled_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundled_state: Option, + pub trust_level: RecipeTrustLevel, + pub risk_level: RecipeRiskLevel, + pub approval_required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RecipeSourceSaveResult { + pub slug: String, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct RecipeWorkspaceIndexEntry { + pub recipe_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub seeded_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bundled_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_digest: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase", default)] +struct RecipeWorkspaceIndex { + #[serde(default)] + pub entries: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BundledRecipeDescriptor { + pub recipe_id: String, + pub version: String, + pub digest: String, +} + +#[derive(Debug, Clone)] +pub struct RecipeWorkspace { + root: PathBuf, +} + +impl RecipeWorkspace { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + + pub fn from_resolved_paths() -> Self { + let root = resolve_paths() + .clawpal_dir + .join("recipes") + .join("workspace"); + Self::new(root) + } + + pub fn list_entries(&self) -> Result, String> { + if !self.root.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + for entry in fs::read_dir(&self.root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + let Some(slug) = file_name.strip_suffix(WORKSPACE_FILE_SUFFIX) else { + continue; + }; + + entries.push(RecipeWorkspaceEntry { + slug: slug.to_string(), + path: path.to_string_lossy().to_string(), + recipe_id: None, + version: None, + source_kind: None, + bundled_version: None, + bundled_state: None, + trust_level: RecipeTrustLevel::Caution, + risk_level: RecipeRiskLevel::Medium, + approval_required: false, + }); + } + + entries.sort_by(|left, right| left.slug.cmp(&right.slug)); + Ok(entries) + } + + pub(crate) fn describe_entries( + &self, + bundled_descriptors: &BTreeMap, + ) -> Result, String> { + let index = self.read_workspace_index()?; + let mut entries = self.list_entries()?; + + for entry in &mut entries { + let source_text = fs::read_to_string(&entry.path).map_err(|error| { + format!("failed to read recipe source '{}': {}", entry.slug, error) + })?; + let recipe = load_recipes_from_source_text(&source_text)? + .into_iter() + .next() + .ok_or_else(|| format!("workspace recipe '{}' is empty", entry.slug))?; + let source_digest = Self::source_digest(&source_text); + let index_entry = index.entries.get(&entry.slug); + let source_kind = index_entry + .and_then(|value| value.source_kind) + .unwrap_or(RecipeWorkspaceSourceKind::LocalImport); + let bundled_state = if source_kind == RecipeWorkspaceSourceKind::Bundled { + bundled_descriptors + .get(&entry.slug) + .map(|descriptor| { + self.bundled_recipe_state_with_seeded_digest( + &entry.slug, + &source_digest, + descriptor.digest.as_str(), + index_entry.and_then(|value| value.seeded_digest.as_deref()), + ) + }) + .transpose()? + } else { + None + }; + let risk_level = risk_level_for_recipe_source(&source_text)?; + let approval_required = approval_required_for(source_kind, risk_level) + && index_entry.and_then(|value| value.approval_digest.as_deref()) + != Some(source_digest.as_str()); + + entry.recipe_id = Some(recipe.id); + entry.version = Some(recipe.version); + entry.source_kind = Some(source_kind); + entry.bundled_version = index_entry.and_then(|value| value.bundled_version.clone()); + entry.bundled_state = bundled_state; + entry.trust_level = trust_level_for_source_kind(source_kind); + entry.risk_level = risk_level; + entry.approval_required = approval_required; + } + + Ok(entries) + } + + pub fn read_recipe_source(&self, slug: &str) -> Result { + let path = self.path_for_slug(slug)?; + fs::read_to_string(&path) + .map_err(|error| format!("failed to read recipe source '{}': {}", slug, error)) + } + + pub fn resolve_recipe_source_path(&self, raw_slug: &str) -> Result { + self.path_for_slug(raw_slug) + .map(|path| path.to_string_lossy().to_string()) + } + + pub fn save_recipe_source( + &self, + raw_slug: &str, + source: &str, + ) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let (recipe_id, _) = parse_recipe_header(source)?; + let saved = self.write_recipe_source(&slug, source)?; + let mut index = self.read_workspace_index()?; + let existing = index.entries.get(&slug).cloned(); + index.entries.insert( + slug.clone(), + RecipeWorkspaceIndexEntry { + recipe_id, + source_kind: existing + .as_ref() + .and_then(|value| value.source_kind) + .or(Some(RecipeWorkspaceSourceKind::LocalImport)), + seeded_digest: existing + .as_ref() + .and_then(|value| value.seeded_digest.clone()), + bundled_version: existing + .as_ref() + .and_then(|value| value.bundled_version.clone()), + approval_digest: None, + }, + ); + self.write_workspace_index(&index)?; + Ok(saved) + } + + pub fn save_imported_recipe_source( + &self, + raw_slug: &str, + source: &str, + source_kind: RecipeWorkspaceSourceKind, + ) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let (recipe_id, _) = parse_recipe_header(source)?; + let saved = self.write_recipe_source(&slug, source)?; + let mut index = self.read_workspace_index()?; + index.entries.insert( + slug.clone(), + RecipeWorkspaceIndexEntry { + recipe_id, + source_kind: Some(source_kind), + seeded_digest: None, + bundled_version: None, + approval_digest: None, + }, + ); + self.write_workspace_index(&index)?; + Ok(saved) + } + + pub fn save_bundled_recipe_source( + &self, + raw_slug: &str, + source: &str, + recipe_id: &str, + bundled_version: &str, + ) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let saved = self.write_recipe_source(&slug, source)?; + let mut index = self.read_workspace_index()?; + index.entries.insert( + slug.clone(), + RecipeWorkspaceIndexEntry { + recipe_id: recipe_id.trim().to_string(), + source_kind: Some(RecipeWorkspaceSourceKind::Bundled), + seeded_digest: Some(Self::source_digest(source)), + bundled_version: Some(bundled_version.trim().to_string()), + approval_digest: None, + }, + ); + self.write_workspace_index(&index)?; + Ok(saved) + } + + pub fn delete_recipe_source(&self, raw_slug: &str) -> Result<(), String> { + let slug = normalize_recipe_slug(raw_slug)?; + let path = self.path_for_slug(&slug)?; + if path.exists() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + self.clear_workspace_index_entry(&slug)?; + Ok(()) + } + + pub fn import_recipe_library( + &self, + root: &PathBuf, + ) -> Result { + crate::recipe_library::import_recipe_library(root, self) + } + + pub(crate) fn bundled_recipe_state( + &self, + raw_slug: &str, + current_bundled_source: &str, + ) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let path = self.path_for_slug(&slug)?; + if !path.exists() { + return Ok(BundledRecipeState::Missing); + } + + let current = fs::read_to_string(&path) + .map_err(|error| format!("failed to read recipe source '{}': {}", slug, error))?; + let current_digest = Self::source_digest(¤t); + let bundled_digest = Self::source_digest(current_bundled_source); + let index = self.read_workspace_index()?; + let seeded_digest = index + .entries + .get(&slug) + .and_then(|entry| entry.seeded_digest.as_deref()); + + self.bundled_recipe_state_with_seeded_digest( + &slug, + ¤t_digest, + &bundled_digest, + seeded_digest, + ) + } + + pub fn approve_recipe(&self, raw_slug: &str, digest: &str) -> Result<(), String> { + let slug = normalize_recipe_slug(raw_slug)?; + let mut index = self.read_workspace_index()?; + let entry = index + .entries + .get_mut(&slug) + .ok_or_else(|| format!("workspace recipe '{}' is not tracked", slug))?; + entry.approval_digest = Some(digest.trim().to_string()); + self.write_workspace_index(&index) + } + + pub fn is_recipe_approved(&self, raw_slug: &str, digest: &str) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let index = self.read_workspace_index()?; + Ok(index + .entries + .get(&slug) + .and_then(|entry| entry.approval_digest.as_deref()) + == Some(digest.trim())) + } + + pub fn source_digest(source: &str) -> String { + recipe_source_digest(source) + } + + pub(crate) fn workspace_source_kind( + &self, + raw_slug: &str, + ) -> Result, String> { + let slug = normalize_recipe_slug(raw_slug)?; + let index = self.read_workspace_index()?; + Ok(index.entries.get(&slug).and_then(|entry| entry.source_kind)) + } + + pub(crate) fn workspace_risk_level(&self, raw_slug: &str) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + let source = self.read_recipe_source(&slug)?; + risk_level_for_recipe_source(&source) + } + + fn path_for_slug(&self, raw_slug: &str) -> Result { + let slug = normalize_recipe_slug(raw_slug)?; + Ok(self.root.join(format!("{}{}", slug, WORKSPACE_FILE_SUFFIX))) + } + + fn write_recipe_source( + &self, + slug: &str, + source: &str, + ) -> Result { + let path = self.root.join(format!("{}{}", slug, WORKSPACE_FILE_SUFFIX)); + write_text(&path, source)?; + Ok(RecipeSourceSaveResult { + slug: slug.to_string(), + path: path.to_string_lossy().to_string(), + }) + } + + fn workspace_index_path(&self) -> PathBuf { + self.root.join(WORKSPACE_INDEX_FILE) + } + + fn read_workspace_index(&self) -> Result { + let path = self.workspace_index_path(); + if !path.exists() { + return Ok(RecipeWorkspaceIndex::default()); + } + + let text = fs::read_to_string(&path) + .map_err(|error| format!("failed to read recipe workspace index: {}", error))?; + json5::from_str::(&text) + .map_err(|error| format!("failed to parse recipe workspace index: {}", error)) + } + + fn write_workspace_index(&self, index: &RecipeWorkspaceIndex) -> Result<(), String> { + let path = self.workspace_index_path(); + if index.entries.is_empty() { + if path.exists() { + fs::remove_file(path).map_err(|error| error.to_string())?; + } + return Ok(()); + } + + let text = serde_json::to_string_pretty(index).map_err(|error| error.to_string())?; + write_text(&path, &text) + } + + fn clear_workspace_index_entry(&self, slug: &str) -> Result<(), String> { + let mut index = self.read_workspace_index()?; + if index.entries.remove(slug).is_some() { + self.write_workspace_index(&index)?; + } + Ok(()) + } + + fn bundled_recipe_state_with_seeded_digest( + &self, + slug: &str, + current_workspace_digest: &str, + current_bundled_digest: &str, + seeded_digest: Option<&str>, + ) -> Result { + let seeded_digest = seeded_digest.ok_or_else(|| { + format!( + "workspace recipe '{}' is missing bundled seed metadata", + slug + ) + })?; + + if current_workspace_digest == seeded_digest { + if current_bundled_digest == seeded_digest { + Ok(BundledRecipeState::UpToDate) + } else { + Ok(BundledRecipeState::UpdateAvailable) + } + } else if current_bundled_digest == seeded_digest { + Ok(BundledRecipeState::LocalModified) + } else { + Ok(BundledRecipeState::ConflictedUpdate) + } + } +} + +fn recipe_source_digest(source: &str) -> String { + Uuid::new_v5(&Uuid::NAMESPACE_URL, source.as_bytes()).to_string() +} + +fn parse_recipe_header(source: &str) -> Result<(String, String), String> { + let recipe = load_recipes_from_source_text(source)? + .into_iter() + .next() + .ok_or_else(|| "recipe source does not contain any recipes".to_string())?; + Ok(( + recipe.id.trim().to_string(), + recipe.version.trim().to_string(), + )) +} + +fn risk_level_for_recipe_source(source: &str) -> Result { + let recipe = load_recipes_from_source_text(source)? + .into_iter() + .next() + .ok_or_else(|| "recipe source does not contain any recipes".to_string())?; + + let action_kinds = if let Some(spec) = recipe.execution_spec_template.as_ref() { + spec.actions + .iter() + .filter_map(|action| action.kind.as_ref()) + .map(|kind| kind.trim().to_string()) + .collect::>() + } else { + recipe + .steps + .iter() + .map(|step| step.action.trim().to_string()) + .collect::>() + }; + + Ok(risk_level_for_action_kinds(&action_kinds)) +} + +fn risk_level_for_action_kinds(action_kinds: &[String]) -> RecipeRiskLevel { + if action_kinds.is_empty() { + return RecipeRiskLevel::Low; + } + + let catalog = crate::recipe_action_catalog::list_recipe_actions(); + let all_read_only = action_kinds.iter().all(|kind| { + catalog + .iter() + .find(|entry| entry.kind == *kind) + .map(|entry| entry.read_only) + .unwrap_or(false) + }); + if all_read_only { + return RecipeRiskLevel::Low; + } + + if action_kinds.iter().any(|kind| { + matches!( + kind.as_str(), + "delete_agent" + | "unbind_agent" + | "delete_model_profile" + | "delete_provider_auth" + | "delete_markdown_document" + | "ensure_model_profile" + | "ensure_provider_auth" + | "set_config_value" + | "unset_config_value" + | "config_patch" + | "apply_secrets_plan" + ) + }) { + return RecipeRiskLevel::High; + } + + RecipeRiskLevel::Medium +} + +pub(crate) fn trust_level_for_source_kind( + source_kind: RecipeWorkspaceSourceKind, +) -> RecipeTrustLevel { + match source_kind { + RecipeWorkspaceSourceKind::Bundled => RecipeTrustLevel::Trusted, + RecipeWorkspaceSourceKind::LocalImport => RecipeTrustLevel::Caution, + RecipeWorkspaceSourceKind::RemoteUrl => RecipeTrustLevel::Untrusted, + } +} + +pub(crate) fn approval_required_for( + source_kind: RecipeWorkspaceSourceKind, + risk_level: RecipeRiskLevel, +) -> bool { + match source_kind { + RecipeWorkspaceSourceKind::Bundled => risk_level == RecipeRiskLevel::High, + RecipeWorkspaceSourceKind::LocalImport | RecipeWorkspaceSourceKind::RemoteUrl => { + risk_level != RecipeRiskLevel::Low + } + } +} + +pub(crate) fn normalize_recipe_slug(raw_slug: &str) -> Result { + let trimmed = raw_slug.trim(); + if trimmed.is_empty() { + return Err("recipe slug cannot be empty".into()); + } + if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("recipe slug contains a disallowed path segment".into()); + } + + let mut slug = String::new(); + let mut last_was_dash = false; + for ch in trimmed.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + continue; + } + + if matches!(ch, '-' | '_' | ' ') { + if !slug.is_empty() && !last_was_dash { + slug.push('-'); + last_was_dash = true; + } + continue; + } + + return Err(format!( + "recipe slug contains unsupported character '{}'", + ch + )); + } + + while slug.ends_with('-') { + slug.pop(); + } + + if slug.is_empty() { + return Err("recipe slug must contain at least one alphanumeric character".into()); + } + + Ok(slug) +} diff --git a/src-tauri/src/recipe_workspace_tests.rs b/src-tauri/src/recipe_workspace_tests.rs new file mode 100644 index 00000000..f5dc9ec4 --- /dev/null +++ b/src-tauri/src/recipe_workspace_tests.rs @@ -0,0 +1,231 @@ +use std::fs; +use std::path::PathBuf; + +use uuid::Uuid; + +use crate::recipe_workspace::{BundledRecipeState, RecipeWorkspace}; + +const SAMPLE_SOURCE: &str = r#"{ + "id": "channel-persona", + "name": "Channel Persona", + "description": "Set a custom persona for a channel", + "version": "1.0.0", + "tags": ["discord", "persona"], + "difficulty": "easy", + "params": [], + "steps": [], + "bundle": { + "apiVersion": "strategy.platform/v1", + "kind": "StrategyBundle", + "metadata": {}, + "compatibility": {}, + "inputs": [], + "capabilities": { "allowed": [] }, + "resources": { "supportedKinds": [] }, + "execution": { "supportedKinds": ["attachment"] }, + "runner": {}, + "outputs": [] + }, + "executionSpecTemplate": { + "apiVersion": "strategy.platform/v1", + "kind": "ExecutionSpec", + "metadata": {}, + "source": {}, + "target": {}, + "execution": { "kind": "attachment" }, + "capabilities": { "usedCapabilities": [] }, + "resources": { "claims": [] }, + "secrets": { "bindings": [] }, + "desiredState": {}, + "actions": [], + "outputs": [] + } +}"#; + +struct TempWorkspaceRoot(PathBuf); + +impl TempWorkspaceRoot { + fn path(&self) -> &PathBuf { + &self.0 + } +} + +impl Drop for TempWorkspaceRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn temp_workspace_root() -> TempWorkspaceRoot { + let root = std::env::temp_dir().join(format!("clawpal-recipe-workspace-{}", Uuid::new_v4())); + fs::create_dir_all(&root).expect("create temp workspace root"); + TempWorkspaceRoot(root) +} + +#[test] +fn workspace_recipe_save_writes_under_clawpal_recipe_workspace() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + let result = store + .save_recipe_source("channel-persona", SAMPLE_SOURCE) + .expect("save recipe source"); + + assert_eq!(result.slug, "channel-persona"); + assert_eq!( + result.path, + root.path() + .join("channel-persona.recipe.json") + .to_string_lossy() + ); + assert!(root.path().join("channel-persona.recipe.json").exists()); +} + +#[test] +fn workspace_recipe_save_rejects_parent_traversal() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + assert!(store + .save_recipe_source("../escape", SAMPLE_SOURCE) + .is_err()); +} + +#[test] +fn delete_workspace_recipe_removes_saved_file() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + let saved = store + .save_recipe_source("persona", SAMPLE_SOURCE) + .expect("save recipe source"); + + store + .delete_recipe_source(saved.slug.as_str()) + .expect("delete recipe source"); + + assert!(!root.path().join("persona.recipe.json").exists()); +} + +#[test] +fn list_workspace_entries_returns_saved_recipes() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + store + .save_recipe_source("zeta", SAMPLE_SOURCE) + .expect("save zeta"); + store + .save_recipe_source("alpha", SAMPLE_SOURCE) + .expect("save alpha"); + + let entries = store.list_entries().expect("list entries"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].slug, "alpha"); + assert_eq!(entries[1].slug, "zeta"); +} + +#[test] +fn bundled_seeded_recipe_is_tracked_until_user_saves_a_workspace_copy() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + store + .save_bundled_recipe_source("channel-persona", SAMPLE_SOURCE, "channel-persona", "1.0.0") + .expect("save bundled recipe"); + + assert_eq!( + store + .bundled_recipe_state("channel-persona", SAMPLE_SOURCE) + .expect("bundled seed status"), + BundledRecipeState::UpToDate + ); + + store + .save_recipe_source( + "channel-persona", + SAMPLE_SOURCE.replace("easy", "normal").as_str(), + ) + .expect("save user recipe"); + + assert_eq!( + store + .bundled_recipe_state("channel-persona", SAMPLE_SOURCE) + .expect("bundled seed status after manual save"), + BundledRecipeState::LocalModified + ); +} + +#[test] +fn bundled_recipe_state_distinguishes_available_update_and_conflicted_update() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + let seeded = SAMPLE_SOURCE; + let updated = SAMPLE_SOURCE + .replace("1.0.0", "1.1.0") + .replace("easy", "normal"); + + store + .save_bundled_recipe_source("channel-persona", seeded, "channel-persona", "1.0.0") + .expect("save bundled recipe"); + + assert_eq!( + store + .bundled_recipe_state("channel-persona", &updated) + .expect("bundled seed status with available update"), + BundledRecipeState::UpdateAvailable + ); + + store + .save_recipe_source( + "channel-persona", + seeded.replace("easy", "advanced").as_str(), + ) + .expect("save local modification"); + + assert_eq!( + store + .bundled_recipe_state("channel-persona", &updated) + .expect("bundled seed status with local conflict"), + BundledRecipeState::ConflictedUpdate + ); +} + +#[test] +fn recipe_approval_digest_is_invalidated_after_workspace_recipe_changes() { + let root = temp_workspace_root(); + let store = RecipeWorkspace::new(root.path().clone()); + + store + .save_bundled_recipe_source("channel-persona", SAMPLE_SOURCE, "channel-persona", "1.0.0") + .expect("save bundled recipe"); + + let initial_source = store + .read_recipe_source("channel-persona") + .expect("read initial source"); + let initial_digest = RecipeWorkspace::source_digest(&initial_source); + store + .approve_recipe("channel-persona", &initial_digest) + .expect("approve bundled recipe"); + + assert!(store + .is_recipe_approved("channel-persona", &initial_digest) + .expect("approval should exist")); + + store + .save_recipe_source( + "channel-persona", + SAMPLE_SOURCE.replace("easy", "normal").as_str(), + ) + .expect("save local change"); + + let next_source = store + .read_recipe_source("channel-persona") + .expect("read updated source"); + let next_digest = RecipeWorkspace::source_digest(&next_source); + + assert_ne!(initial_digest, next_digest); + assert!(!store + .is_recipe_approved("channel-persona", &next_digest) + .expect("approval should be invalidated")); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9ef9c95d..51895d49 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -40,7 +40,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": ["resources/watchdog.js"], + "resources": ["resources/watchdog.js", "../examples/recipe-library"], "targets": "all", "macOS": { "minimumSystemVersion": "10.15", diff --git a/src-tauri/tests/docker_profile_sync_e2e.rs b/src-tauri/tests/docker_profile_sync_e2e.rs index d95fad63..ba6309f7 100644 --- a/src-tauri/tests/docker_profile_sync_e2e.rs +++ b/src-tauri/tests/docker_profile_sync_e2e.rs @@ -17,16 +17,19 @@ use clawpal::ssh::{SshConnectionPool, SshHostConfig}; use std::process::Command; +use std::sync::OnceLock; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CONTAINER_NAME: &str = "clawpal-e2e-docker-sync"; -const SSH_PORT: u16 = 2299; +const DEFAULT_SSH_PORT: u16 = 2299; const ROOT_PASSWORD: &str = "clawpal-e2e-pass"; const TEST_ANTHROPIC_KEY: &str = "test-anthropic-profile-key"; const TEST_OPENAI_KEY: &str = "test-openai-profile-key"; +static TEST_SSH_PORT: OnceLock = OnceLock::new(); +static CLEAN_START: OnceLock<()> = OnceLock::new(); /// Dockerfile: Ubuntu + openssh-server + Node.js + pinned real openclaw CLI + seeded OpenClaw config. const DOCKERFILE: &str = r#" @@ -51,24 +54,43 @@ RUN mkdir -p /root/.openclaw/agents/main/agent # Main openclaw config (JSON5 compatible) RUN cat > /root/.openclaw/openclaw.json <<'OCEOF' { + "meta": { + "lastTouchedVersion": "2026.3.2", + "lastTouchedAt": "2026-03-12T17:59:58.553Z" + }, "gateway": { "port": 18789, - "token": "gw-test-token-abc123" - }, - "defaults": { - "model": "anthropic/claude-sonnet-4-20250514" + "mode": "local", + "auth": { + "token": "gw-test-token-abc123" + } }, "models": { - "anthropic/claude-sonnet-4-20250514": { - "provider": "anthropic", - "model": "claude-sonnet-4-20250514" - }, - "openai/gpt-4o": { - "provider": "openai", - "model": "gpt-4o" + "providers": { + "anthropic": { + "baseUrl": "https://api.anthropic.com/v1", + "models": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4" + } + ] + }, + "openai": { + "baseUrl": "https://api.openai.com/v1", + "models": [ + { + "id": "gpt-4o", + "name": "GPT-4o" + } + ] + } } }, "agents": { + "defaults": { + "model": "anthropic/claude-sonnet-4-20250514" + }, "list": [ { "id": "main", "model": "anthropic/claude-sonnet-4-20250514" } ] @@ -100,18 +122,35 @@ AUTHEOF # openclaw: exact published version — no floating @latest tag. ARG NODE_VERSION=24.13.0 ARG OPENCLAW_VERSION=2026.3.2 +ARG TARGETARCH RUN apt-get update && \ - apt-get install -y curl ca-certificates xz-utils && \ + apt-get install -y curl ca-certificates git xz-utils && \ rm -rf /var/lib/apt/lists/* && \ - curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \ + case "${TARGETARCH}" in \ + amd64) NODE_ARCH="x64" ;; \ + arm64) NODE_ARCH="arm64" ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac && \ + curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ -o /tmp/node.tar.xz && \ tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 && \ rm /tmp/node.tar.xz && \ - npm install -g "openclaw@${OPENCLAW_VERSION}" + npm config set fetch-retries 5 && \ + npm config set fetch-retry-mintimeout 10000 && \ + npm config set fetch-retry-maxtimeout 120000 && \ + for attempt in 1 2 3; do \ + npm install -g "openclaw@${OPENCLAW_VERSION}" && break; \ + if [ "$attempt" -eq 3 ]; then exit 1; fi; \ + echo "openclaw install failed on attempt ${attempt}, retrying..." >&2; \ + sleep 5; \ + done # Set env vars that ClawPal profile sync checks RUN echo "export ANTHROPIC_API_KEY=ANTHROPIC_KEY" >> /root/.bashrc && \ - echo "export OPENAI_API_KEY=OPENAI_KEY" >> /root/.bashrc + echo "export OPENAI_API_KEY=OPENAI_KEY" >> /root/.bashrc && \ + echo "export ANTHROPIC_API_KEY=ANTHROPIC_KEY" >> /root/.profile && \ + echo "export OPENAI_API_KEY=OPENAI_KEY" >> /root/.profile EXPOSE 22 CMD ["/usr/sbin/sshd", "-D"] @@ -125,6 +164,14 @@ fn should_run() -> bool { std::env::var("CLAWPAL_RUN_DOCKER_SYNC_E2E").ok().as_deref() == Some("1") } +fn ensure_exec_timeout_override() { + std::env::set_var("CLAWPAL_RUSSH_EXEC_TIMEOUT_SECS", "60"); +} + +fn docker_ssh_port() -> u16 { + *TEST_SSH_PORT.get_or_init(|| portpicker::pick_unused_port().unwrap_or(DEFAULT_SSH_PORT)) +} + fn docker_available() -> bool { Command::new("docker") .args(["info"]) @@ -151,6 +198,13 @@ fn cleanup_image() { .status(); } +fn ensure_clean_start() { + CLEAN_START.get_or_init(|| { + cleanup_container(); + cleanup_image(); + }); +} + fn build_image() -> Result<(), String> { let dockerfile = DOCKERFILE .replace("ROOTPASS", ROOT_PASSWORD) @@ -187,6 +241,7 @@ fn build_image() -> Result<(), String> { } fn start_container() -> Result<(), String> { + let ssh_port = docker_ssh_port(); let output = Command::new("docker") .args([ "run", @@ -194,7 +249,7 @@ fn start_container() -> Result<(), String> { "--name", CONTAINER_NAME, "-p", - &format!("{}:22", SSH_PORT), + &format!("{ssh_port}:22"), &format!("{CONTAINER_NAME}:latest"), ]) .output() @@ -208,6 +263,7 @@ fn start_container() -> Result<(), String> { } fn wait_for_ssh(timeout_secs: u64) -> Result<(), String> { + let ssh_port = docker_ssh_port(); let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(timeout_secs); loop { @@ -215,7 +271,7 @@ fn wait_for_ssh(timeout_secs: u64) -> Result<(), String> { return Err("timeout waiting for SSH to become available".into()); } let result = std::net::TcpStream::connect_timeout( - &format!("127.0.0.1:{SSH_PORT}").parse().unwrap(), + &format!("127.0.0.1:{ssh_port}").parse().unwrap(), std::time::Duration::from_secs(1), ); if result.is_ok() { @@ -232,7 +288,7 @@ fn docker_host_config() -> SshHostConfig { id: "e2e-docker-sync".into(), label: "E2E Docker Sync".into(), host: "127.0.0.1".into(), - port: SSH_PORT, + port: docker_ssh_port(), username: "root".into(), auth_method: "password".into(), key_path: None, @@ -257,6 +313,8 @@ async fn e2e_docker_profile_sync_and_doctor() { eprintln!("skip: docker not available"); return; } + ensure_exec_timeout_override(); + ensure_clean_start(); // Cleanup any leftover container from previous runs cleanup_container(); @@ -303,9 +361,9 @@ async fn e2e_docker_profile_sync_and_doctor() { assert_eq!(gateway_port, 18789); let default_model = config - .pointer("/defaults/model") + .pointer("/agents/defaults/model") .and_then(|v| v.as_str()) - .expect("defaults.model should exist"); + .expect("agents.defaults.model should exist"); assert_eq!(default_model, "anthropic/claude-sonnet-4-20250514"); eprintln!("[e2e] Config verified: gateway port={gateway_port}, default model={default_model}"); @@ -333,19 +391,16 @@ async fn e2e_docker_profile_sync_and_doctor() { // --- Step 4: Extract model profiles from config --- // Verify models are defined in the config let models = config - .get("models") + .pointer("/models/providers") .and_then(|v| v.as_object()) - .expect("models should be an object"); - assert!( - models.contains_key("anthropic/claude-sonnet-4-20250514"), - "should have anthropic model" - ); + .expect("models.providers should be an object"); assert!( - models.contains_key("openai/gpt-4o"), - "should have openai model" + models.contains_key("anthropic"), + "should have anthropic provider" ); + assert!(models.contains_key("openai"), "should have openai provider"); eprintln!( - "[e2e] Model profiles extracted: {} models found", + "[e2e] Model providers extracted: {} providers found", models.len() ); @@ -370,7 +425,7 @@ async fn e2e_docker_profile_sync_and_doctor() { // --- Step 6: Run doctor check --- let doctor_result = pool - .exec(&cfg.id, "openclaw doctor --json") + .exec(&cfg.id, "openclaw doctor --non-interactive") .await .expect("openclaw doctor should succeed"); assert_eq!( @@ -378,30 +433,19 @@ async fn e2e_docker_profile_sync_and_doctor() { "doctor should exit 0, stderr: {}", doctor_result.stderr ); - - let doctor: serde_json::Value = - serde_json::from_str(&doctor_result.stdout).expect("doctor output should be valid JSON"); - assert_eq!( - doctor.get("ok").and_then(|v| v.as_bool()), - Some(true), - "doctor should report ok=true" + assert!( + doctor_result.stdout.contains("Doctor complete."), + "doctor output should contain completion marker: {}", + doctor_result.stdout ); - assert_eq!( - doctor.get("score").and_then(|v| v.as_u64()), - Some(100), - "doctor should report score=100" + assert!( + doctor_result + .stdout + .contains("Gateway target: ws://127.0.0.1:18789"), + "doctor output should report the configured gateway target: {}", + doctor_result.stdout ); - - let checks = doctor - .get("checks") - .and_then(|v| v.as_array()) - .expect("doctor should have checks array"); - assert!(!checks.is_empty(), "doctor should have at least one check"); - for check in checks { - let status = check.get("status").and_then(|v| v.as_str()).unwrap_or(""); - assert_eq!(status, "ok", "check {:?} should be ok", check.get("id")); - } - eprintln!("[e2e] Doctor check passed: {} checks all ok", checks.len()); + eprintln!("[e2e] Doctor check passed"); // --- Step 7: Verify env vars accessible via exec --- let env_result = pool @@ -470,6 +514,8 @@ async fn e2e_docker_password_auth_connect() { eprintln!("skip: docker not available"); return; } + ensure_exec_timeout_override(); + ensure_clean_start(); // Reuse container from previous test if running together, or build fresh let needs_setup = Command::new("docker") @@ -534,6 +580,8 @@ async fn e2e_docker_wrong_password_rejected() { eprintln!("skip: docker not available"); return; } + ensure_exec_timeout_override(); + ensure_clean_start(); // Container must be running let running = Command::new("docker") diff --git a/src-tauri/tests/recipe_docker_e2e.rs b/src-tauri/tests/recipe_docker_e2e.rs new file mode 100644 index 00000000..754dbc4d --- /dev/null +++ b/src-tauri/tests/recipe_docker_e2e.rs @@ -0,0 +1,619 @@ +//! E2E test: import the bundled recipe library into a temporary ClawPal +//! workspace, then execute the three business recipes against a real OpenClaw +//! CLI running inside a Dockerized Ubuntu host exposed over SSH. +//! +//! Guarded by `CLAWPAL_RUN_DOCKER_RECIPE_E2E=1`. + +use clawpal::cli_runner::{ + set_active_clawpal_data_override, set_active_openclaw_home_override, CliCache, CommandQueue, + RemoteCommandQueues, +}; +use clawpal::commands::{ + approve_recipe_workspace_source, execute_recipe_with_services, import_recipe_library, + list_recipe_runs, read_recipe_workspace_source, +}; +use clawpal::recipe_executor::ExecuteRecipeRequest; +use clawpal::recipe_planner::build_recipe_plan_from_source_text; +use clawpal::recipe_workspace::RecipeWorkspace; +use clawpal::ssh::{SshConnectionPool, SshHostConfig}; +use serde_json::{json, Map, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; + +const CONTAINER_NAME: &str = "clawpal-e2e-recipe-library"; +const ROOT_PASSWORD: &str = "clawpal-e2e-pass"; +const TEST_ANTHROPIC_KEY: &str = "test-anthropic-recipe-key"; +const TEST_OPENAI_KEY: &str = "test-openai-recipe-key"; + +const DOCKERFILE: &str = r#" +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y openssh-server curl ca-certificates git xz-utils && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir /var/run/sshd + +RUN echo "root:ROOTPASS" | chpasswd && \ + sed -i 's/#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + sed -i 's/PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \ + echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config + +RUN mkdir -p /root/.openclaw/agents/main/agent +RUN mkdir -p /root/.openclaw/instances/openclaw-recipe-e2e/workspace + +RUN cat > /root/.openclaw/openclaw.json <<'OCEOF' +{ + "meta": { + "lastTouchedVersion": "2026.3.2", + "lastTouchedAt": "2026-03-12T17:59:58.553Z" + }, + "gateway": { + "port": 18789, + "mode": "local", + "auth": { + "token": "gw-test-token-abc123" + } + }, + "models": { + "providers": { + "anthropic": { + "baseUrl": "https://api.anthropic.com/v1", + "models": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4" + } + ] + } + } + }, + "agents": { + "defaults": { + "model": "anthropic/claude-sonnet-4-20250514", + "workspace": "~/.openclaw/instances/openclaw-recipe-e2e/workspace" + }, + "list": [ + { + "id": "main", + "model": "anthropic/claude-sonnet-4-20250514", + "workspace": "~/.openclaw/instances/openclaw-recipe-e2e/workspace" + } + ] + }, + "channels": { + "discord": { + "enabled": true, + "groupPolicy": "allowlist", + "streaming": "off", + "guilds": { + "guild-recipe-lab": { + "channels": { + "channel-general": { + "systemPrompt": "" + }, + "channel-support": { + "systemPrompt": "" + } + } + } + } + } + } +} +OCEOF + +RUN cat > /root/.openclaw/agents/main/agent/IDENTITY.md <<'IDEOF' +- Name: Main Agent +- Emoji: 🤖 +IDEOF + +RUN cat > /root/.openclaw/agents/main/agent/auth-profiles.json <<'AUTHEOF' +{ + "version": 1, + "profiles": { + "anthropic:default": { + "type": "token", + "provider": "anthropic", + "token": "ANTHROPIC_KEY" + }, + "openai:default": { + "type": "token", + "provider": "openai", + "token": "OPENAI_KEY" + } + } +} +AUTHEOF + +ARG NODE_VERSION=24.13.0 +ARG OPENCLAW_VERSION=2026.3.2 +ARG TARGETARCH +RUN case "${TARGETARCH}" in \ + amd64) NODE_ARCH="x64" ;; \ + arm64) NODE_ARCH="arm64" ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac && \ + curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \ + -o /tmp/node.tar.xz && \ + tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 && \ + rm /tmp/node.tar.xz && \ + npm config set fetch-retries 5 && \ + npm config set fetch-retry-mintimeout 10000 && \ + npm config set fetch-retry-maxtimeout 120000 && \ + for attempt in 1 2 3; do \ + npm install -g "openclaw@${OPENCLAW_VERSION}" && break; \ + if [ "$attempt" -eq 3 ]; then exit 1; fi; \ + echo "openclaw install failed on attempt ${attempt}, retrying..." >&2; \ + sleep 5; \ + done + +RUN echo "export ANTHROPIC_API_KEY=ANTHROPIC_KEY" >> /root/.bashrc && \ + echo "export OPENAI_API_KEY=OPENAI_KEY" >> /root/.bashrc && \ + echo "export ANTHROPIC_API_KEY=ANTHROPIC_KEY" >> /root/.profile && \ + echo "export OPENAI_API_KEY=OPENAI_KEY" >> /root/.profile + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D"] +"#; + +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn temp_dir(prefix: &str) -> TempDir { + let path = std::env::temp_dir().join(format!("clawpal-{}-{}", prefix, Uuid::new_v4())); + fs::create_dir_all(&path).expect("create temp dir"); + TempDir(path) +} + +struct OverrideGuard; + +impl OverrideGuard { + fn new(openclaw_home: &Path, clawpal_data_dir: &Path) -> Self { + set_active_openclaw_home_override(Some(openclaw_home.to_string_lossy().to_string())) + .expect("set active openclaw home override"); + set_active_clawpal_data_override(Some(clawpal_data_dir.to_string_lossy().to_string())) + .expect("set active clawpal data override"); + Self + } +} + +impl Drop for OverrideGuard { + fn drop(&mut self) { + let _ = set_active_openclaw_home_override(None); + let _ = set_active_clawpal_data_override(None); + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + std::env::set_var(self.key, previous); + } else { + std::env::remove_var(self.key); + } + } +} + +struct ContainerCleanup; + +impl Drop for ContainerCleanup { + fn drop(&mut self) { + cleanup_container(); + cleanup_image(); + } +} + +fn should_run() -> bool { + std::env::var("CLAWPAL_RUN_DOCKER_RECIPE_E2E") + .ok() + .as_deref() + == Some("1") +} + +fn docker_available() -> bool { + Command::new("docker") + .args(["info"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn cleanup_container() { + let _ = Command::new("docker") + .args(["rm", "-f", CONTAINER_NAME]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +fn cleanup_image() { + let _ = Command::new("docker") + .args(["rmi", "-f", &format!("{CONTAINER_NAME}:latest")]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); +} + +fn build_image() -> Result<(), String> { + let dockerfile = DOCKERFILE + .replace("ROOTPASS", ROOT_PASSWORD) + .replace("ANTHROPIC_KEY", TEST_ANTHROPIC_KEY) + .replace("OPENAI_KEY", TEST_OPENAI_KEY); + let output = Command::new("docker") + .args([ + "build", + "-t", + &format!("{CONTAINER_NAME}:latest"), + "-f", + "-", + ".", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir(std::env::temp_dir()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + stdin.write_all(dockerfile.as_bytes())?; + } + child.wait_with_output() + }) + .map_err(|error| format!("docker build failed to spawn: {error}"))?; + + if !output.status.success() { + return Err(format!( + "docker build failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) +} + +fn start_container(ssh_port: u16) -> Result<(), String> { + let output = Command::new("docker") + .args([ + "run", + "-d", + "--name", + CONTAINER_NAME, + "-p", + &format!("{ssh_port}:22"), + &format!("{CONTAINER_NAME}:latest"), + ]) + .output() + .map_err(|error| format!("docker run failed: {error}"))?; + + if !output.status.success() { + return Err(format!( + "docker run failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) +} + +fn wait_for_ssh(port: u16, timeout_secs: u64) -> Result<(), String> { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(timeout_secs); + let addr = format!("127.0.0.1:{port}") + .parse() + .expect("parse docker ssh address"); + loop { + if start.elapsed() > timeout { + return Err("timeout waiting for SSH to become available".into()); + } + if std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(1)).is_ok() { + std::thread::sleep(std::time::Duration::from_millis(500)); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(300)); + } +} + +fn docker_host_config(ssh_port: u16) -> SshHostConfig { + SshHostConfig { + id: "recipe-e2e-docker".into(), + label: "Recipe E2E Docker".into(), + host: "127.0.0.1".into(), + port: ssh_port, + username: "root".into(), + auth_method: "password".into(), + key_path: None, + password: Some(ROOT_PASSWORD.into()), + passphrase: None, + } +} + +fn recipe_library_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("examples") + .join("recipe-library") +} + +async fn execute_workspace_recipe( + queue: &CommandQueue, + cache: &CliCache, + pool: &SshConnectionPool, + remote_queues: &RemoteCommandQueues, + host_id: &str, + workspace_slug: &str, + recipe_id: &str, + params: Map, +) -> Result { + approve_recipe_workspace_source(workspace_slug.to_string())?; + let source = read_recipe_workspace_source(workspace_slug.to_string())?; + let mut plan = build_recipe_plan_from_source_text(recipe_id, ¶ms, &source)?; + plan.execution_spec.target = json!({ + "kind": "remote_ssh", + "hostId": host_id, + }); + + execute_recipe_with_services( + queue, + cache, + pool, + remote_queues, + ExecuteRecipeRequest { + spec: plan.execution_spec, + source_origin: Some("saved".into()), + source_text: Some(source), + workspace_slug: Some(workspace_slug.into()), + }, + ) + .await +} + +fn sample_dedicated_params() -> Map { + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("ops-bot".into())); + params.insert("model".into(), Value::String("__default__".into())); + params.insert("name".into(), Value::String("Ops Bot".into())); + params.insert("emoji".into(), Value::String("🛰️".into())); + params.insert( + "persona".into(), + Value::String("You coordinate incident response with crisp updates.".into()), + ); + params +} + +fn sample_agent_persona_params() -> Map { + let mut params = Map::new(); + params.insert("agent_id".into(), Value::String("main".into())); + params.insert("persona_preset".into(), Value::String("coach".into())); + params +} + +fn sample_channel_persona_params() -> Map { + let mut params = Map::new(); + params.insert("guild_id".into(), Value::String("guild-recipe-lab".into())); + params.insert("channel_id".into(), Value::String("channel-support".into())); + params.insert("persona_preset".into(), Value::String("support".into())); + params +} + +#[tokio::test] +async fn e2e_recipe_library_import_and_execute_against_docker_openclaw() { + if !should_run() { + eprintln!("skip: set CLAWPAL_RUN_DOCKER_RECIPE_E2E=1 to enable"); + return; + } + if !docker_available() { + eprintln!("skip: docker not available"); + return; + } + + let ssh_port = portpicker::pick_unused_port().unwrap_or(2301); + let test_root = temp_dir("recipe-docker-e2e"); + let _overrides = OverrideGuard::new( + &test_root.path().join("openclaw-home"), + &test_root.path().join("clawpal-data"), + ); + let _exec_timeout = EnvVarGuard::set("CLAWPAL_RUSSH_EXEC_TIMEOUT_SECS", "60"); + let _cleanup = ContainerCleanup; + + cleanup_container(); + build_image().expect("docker image build should succeed"); + start_container(ssh_port).expect("docker container should start"); + wait_for_ssh(ssh_port, 45).expect("ssh should become available"); + + let pool = SshConnectionPool::new(); + let queue = CommandQueue::new(); + let cache = CliCache::new(); + let remote_queues = RemoteCommandQueues::new(); + let host = docker_host_config(ssh_port); + pool.connect(&host) + .await + .expect("ssh connect to docker recipe host should succeed"); + + let import_result = import_recipe_library(recipe_library_root().to_string_lossy().to_string()) + .expect("import example recipe library"); + assert_eq!(import_result.imported.len(), 3); + assert!(import_result.skipped.is_empty()); + assert_eq!( + RecipeWorkspace::from_resolved_paths() + .list_entries() + .expect("list workspace recipes") + .len(), + 3 + ); + + let dedicated_result = execute_workspace_recipe( + &queue, + &cache, + &pool, + &remote_queues, + &host.id, + "dedicated-agent", + "dedicated-agent", + sample_dedicated_params(), + ) + .await + .expect("execute dedicated agent recipe"); + assert_eq!(dedicated_result.instance_id, host.id); + assert_eq!( + dedicated_result.summary, + "Created dedicated agent Ops Bot (ops-bot)" + ); + + let remote_config_raw = pool + .sftp_read(&host.id, "~/.openclaw/openclaw.json") + .await + .expect("read remote openclaw config"); + let remote_config: Value = + serde_json::from_str(&remote_config_raw).expect("remote config should be valid json"); + let agents = remote_config + .pointer("/agents/list") + .and_then(Value::as_array) + .expect("remote agents list"); + let dedicated_agent = agents + .iter() + .find(|agent| agent.get("id").and_then(Value::as_str) == Some("ops-bot")) + .expect("ops-bot should exist in remote agents list"); + let dedicated_workspace = dedicated_agent + .get("workspace") + .and_then(Value::as_str) + .expect("dedicated agent should have workspace"); + assert!( + dedicated_workspace.starts_with('/') || dedicated_workspace.starts_with("~/"), + "expected OpenClaw to return an absolute or home-relative workspace, got: {dedicated_workspace}" + ); + assert_eq!( + dedicated_agent.get("agentDir").and_then(Value::as_str), + Some("/root/.openclaw/agents/ops-bot/agent") + ); + if let Some(model) = dedicated_agent.get("model").and_then(Value::as_str) { + assert_eq!(model, "anthropic/claude-sonnet-4-20250514"); + } + + let dedicated_identity = match pool + .sftp_read(&host.id, "~/.openclaw/agents/ops-bot/agent/IDENTITY.md") + .await + { + Ok(identity) => identity, + Err(_) => pool + .sftp_read(&host.id, &format!("{dedicated_workspace}/IDENTITY.md")) + .await + .expect("read dedicated agent identity"), + }; + assert!( + dedicated_identity.contains("Ops Bot"), + "expected identity to preserve display name, got:\n{dedicated_identity}" + ); + assert!( + dedicated_identity.contains("🛰️"), + "expected identity to preserve emoji, got:\n{dedicated_identity}" + ); + assert!( + dedicated_identity.contains("## Persona"), + "expected identity to include persona section, got:\n{dedicated_identity}" + ); + assert!( + dedicated_identity.contains("incident response"), + "expected identity to include persona content, got:\n{dedicated_identity}" + ); + + let agent_persona_result = execute_workspace_recipe( + &queue, + &cache, + &pool, + &remote_queues, + &host.id, + "agent-persona-pack", + "agent-persona-pack", + sample_agent_persona_params(), + ) + .await + .expect("execute agent persona recipe"); + assert_eq!( + agent_persona_result.summary, + "Updated persona for agent main" + ); + + let main_identity = pool + .sftp_read(&host.id, "~/.openclaw/agents/main/agent/IDENTITY.md") + .await + .expect("read main identity"); + assert!(main_identity.contains("- Name: Main Agent")); + assert!(main_identity.contains("- Emoji: 🤖")); + assert!(main_identity.contains("## Persona")); + assert!(main_identity.contains("focused coaching agent")); + + let channel_persona_result = execute_workspace_recipe( + &queue, + &cache, + &pool, + &remote_queues, + &host.id, + "channel-persona-pack", + "channel-persona-pack", + sample_channel_persona_params(), + ) + .await + .expect("execute channel persona recipe"); + assert_eq!( + channel_persona_result.summary, + "Updated persona for channel channel-support" + ); + + let updated_config_raw = pool + .sftp_read(&host.id, "~/.openclaw/openclaw.json") + .await + .expect("read updated remote config"); + let updated_config: Value = + serde_json::from_str(&updated_config_raw).expect("updated config should be valid json"); + assert_eq!( + updated_config + .pointer("/channels/discord/guilds/guild-recipe-lab/channels/channel-support/systemPrompt") + .and_then(Value::as_str), + Some( + "You are the support concierge for this channel.\n\nWelcome users, ask clarifying questions, and turn vague requests into clean next steps.\n" + ) + ); + + let runs = list_recipe_runs(Some(host.id.clone())).expect("list recipe runs for docker host"); + assert_eq!(runs.len(), 3); + assert!(runs.iter().all(|run| run.status == "succeeded")); + assert!(runs + .iter() + .any(|run| run.summary == dedicated_result.summary)); + assert!(runs + .iter() + .any(|run| run.summary == agent_persona_result.summary)); + assert!(runs + .iter() + .any(|run| run.summary == channel_persona_result.summary)); +} diff --git a/src/App.tsx b/src/App.tsx index de55dd39..1b349f3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,7 +39,20 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn, formatBytes } from "@/lib/utils"; import { toast, Toaster } from "sonner"; -import type { ChannelNode, DiscordGuildChannel, DiscoveredInstance, DockerInstance, InstallSession, PrecheckIssue, RegisteredInstance, SshHost, SshTransferStats } from "./lib/types"; +import type { + ChannelNode, + DiscordGuildChannel, + DiscoveredInstance, + DockerInstance, + InstallSession, + PrecheckIssue, + RecipeEditorOrigin, + RecipeStudioDraft, + RecipeSourceOrigin, + RegisteredInstance, + SshHost, + SshTransferStats, +} from "./lib/types"; import { SshFormWidget } from "./components/SshFormWidget"; import { closeWorkspaceTab } from "@/lib/tabWorkspace"; import { @@ -48,9 +61,11 @@ import { buildSshPassphraseConnectErrorMessage, } from "@/lib/sshConnectErrors"; import { buildFriendlySshError, extractErrorText } from "@/lib/sshDiagnostic"; +import { shouldShowPendingChangesBar } from "@/lib/route-ui"; const Home = lazy(() => import("./pages/Home").then((m) => ({ default: m.Home }))); const Recipes = lazy(() => import("./pages/Recipes").then((m) => ({ default: m.Recipes }))); +const RecipeStudio = lazy(() => import("./pages/RecipeStudio").then((m) => ({ default: m.RecipeStudio }))); const Cook = lazy(() => import("./pages/Cook").then((m) => ({ default: m.Cook }))); const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History }))); const Settings = lazy(() => import("./pages/Settings").then((m) => ({ default: m.Settings }))); @@ -66,6 +81,7 @@ const preloadRouteModules = () => import("./pages/Home"), import("./pages/Channels"), import("./pages/Recipes"), + import("./pages/RecipeStudio"), import("./pages/Cron"), import("./pages/Doctor"), import("./pages/OpenclawContext"), @@ -80,7 +96,7 @@ const DEFAULT_DOCKER_OPENCLAW_HOME = "~/.clawpal/docker-local"; const DEFAULT_DOCKER_CLAWPAL_DATA_DIR = "~/.clawpal/docker-local/data"; const DEFAULT_DOCKER_INSTANCE_ID = "docker:local"; -type Route = "home" | "recipes" | "cook" | "history" | "channels" | "cron" | "doctor" | "context" | "orchestrator"; +type Route = "home" | "recipes" | "recipe-studio" | "cook" | "history" | "channels" | "cron" | "doctor" | "context" | "orchestrator"; const INSTANCE_ROUTES: Route[] = ["home", "channels", "recipes", "cron", "doctor", "context", "history"]; const OPEN_TABS_STORAGE_KEY = "clawpal_open_tabs"; const APP_PREFERENCES_CACHE_KEY = buildCacheKey("__global__", "getAppPreferences", []); @@ -159,6 +175,15 @@ export function App() { const [route, setRoute] = useState("home"); const [recipeId, setRecipeId] = useState(null); const [recipeSource, setRecipeSource] = useState(undefined); + const [recipeSourceText, setRecipeSourceText] = useState(undefined); + const [recipeSourceOrigin, setRecipeSourceOrigin] = useState("saved"); + const [recipeSourceWorkspaceSlug, setRecipeSourceWorkspaceSlug] = useState(undefined); + const [recipeEditorRecipeId, setRecipeEditorRecipeId] = useState(null); + const [recipeEditorRecipeName, setRecipeEditorRecipeName] = useState(""); + const [recipeEditorSource, setRecipeEditorSource] = useState(""); + const [recipeEditorOrigin, setRecipeEditorOrigin] = useState("builtin"); + const [recipeEditorWorkspaceSlug, setRecipeEditorWorkspaceSlug] = useState(undefined); + const [cookReturnRoute, setCookReturnRoute] = useState("recipes"); const [channelNodes, setChannelNodes] = useState(null); const [discordGuildChannels, setDiscordGuildChannels] = useState(null); const [channelsLoading, setChannelsLoading] = useState(false); @@ -191,6 +216,14 @@ export function App() { const navigateRoute = useCallback((next: Route) => { startTransition(() => setRoute(next)); }, []); + const openRecipeStudio = useCallback((draft: RecipeStudioDraft) => { + setRecipeEditorRecipeId(draft.recipeId); + setRecipeEditorRecipeName(draft.recipeName); + setRecipeEditorSource(draft.source); + setRecipeEditorOrigin(draft.origin); + setRecipeEditorWorkspaceSlug(draft.workspaceSlug); + navigateRoute("recipe-studio"); + }, [navigateRoute]); const handleEditSsh = useCallback((host: SshHost) => { setEditingSshHost(host); @@ -1466,6 +1499,7 @@ export function App() { /> t.id === activeInstance)?.label || activeInstance, instanceViewToken: activeInstance, instanceToken, persistenceScope, @@ -1558,7 +1592,7 @@ export function App() { )} - {!inStart && ( + {shouldShowPendingChangesBar({ inStart, route }) && ( { + onCook={(id, options) => { setRecipeId(id); - setRecipeSource(source); + setRecipeSource(options?.source); + setRecipeSourceText(options?.sourceText); + setRecipeSourceOrigin(options?.sourceOrigin ?? "saved"); + setRecipeSourceWorkspaceSlug(options?.workspaceSlug); + setCookReturnRoute("recipes"); + navigateRoute("cook"); + }} + onOpenStudio={openRecipeStudio} + onOpenRuntimeDashboard={() => navigateRoute("orchestrator")} + /> + )} + {!inStart && route === "recipe-studio" && recipeEditorRecipeId && ( + { + setRecipeId(draft.recipeId); + setRecipeSource(undefined); + setRecipeSourceText(draft.source); + setRecipeSourceOrigin("draft"); + setRecipeSourceWorkspaceSlug(draft.workspaceSlug); + setCookReturnRoute("recipe-studio"); + setRecipeEditorRecipeId(draft.recipeId); + setRecipeEditorRecipeName(draft.recipeName); + setRecipeEditorSource(draft.source); + setRecipeEditorOrigin(draft.origin); + setRecipeEditorWorkspaceSlug(draft.workspaceSlug); navigateRoute("cook"); }} + onBack={() => navigateRoute("recipes")} /> )} + {!inStart && route === "recipe-studio" && !recipeEditorRecipeId && ( +

{t("recipeStudio.noRecipeSelected")}

+ )} {!inStart && route === "cook" && recipeId && ( navigateRoute("history")} + onOpenRuntimeDashboard={() => navigateRoute("orchestrator")} onDone={() => { - navigateRoute("recipes"); + navigateRoute(cookReturnRoute); }} /> )} @@ -1692,7 +1764,12 @@ export function App() { /> )} {!inStart && route === "cron" && } - {!inStart && route === "history" && } + {!inStart && route === "history" && ( + navigateRoute("orchestrator")} + /> + )} {!inStart && route === "doctor" && ( )} diff --git a/src/components/CreateAgentDialog.tsx b/src/components/CreateAgentDialog.tsx index 5d32ae7c..8bc101e6 100644 --- a/src/components/CreateAgentDialog.tsx +++ b/src/components/CreateAgentDialog.tsx @@ -4,7 +4,6 @@ import { useApi } from "@/lib/use-api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Select, @@ -33,19 +32,18 @@ export function CreateAgentDialog({ onOpenChange, modelProfiles, onCreated, + allowPersona = false, }: { open: boolean; onOpenChange: (open: boolean) => void; modelProfiles: ModelProfile[]; onCreated: (result: CreateAgentResult) => void; + allowPersona?: boolean; }) { const { t } = useTranslation(); const ua = useApi(); const [agentId, setAgentId] = useState(""); const [model, setModel] = useState(""); - const [independent, setIndependent] = useState(false); - const [displayName, setDisplayName] = useState(""); - const [emoji, setEmoji] = useState(""); const [persona, setPersona] = useState(""); const [creating, setCreating] = useState(false); const [error, setError] = useState(""); @@ -53,9 +51,6 @@ export function CreateAgentDialog({ const reset = () => { setAgentId(""); setModel(""); - setIndependent(false); - setDisplayName(""); - setEmoji(""); setPersona(""); setError(""); }; @@ -77,38 +72,44 @@ export function CreateAgentDialog({ return profileToModelValue(profile); }; const modelValue = resolveModelValue(model || undefined); - - // Build CLI command for queue - // --non-interactive requires --workspace; for non-independent agents - // we must resolve the default workspace from config. - const command: string[] = ["openclaw", "agents", "add", id, "--non-interactive"]; - if (modelValue) { - command.push("--model", modelValue); - } - if (independent) { - command.push("--workspace", id); - } else { - // Resolve default workspace: from config, or from existing agents - let defaultWs: string | undefined; + if (ua.isRemote) { + let workspace: string | undefined; try { const rawConfig = await ua.readRawConfig(); const cfg = JSON.parse(rawConfig); - defaultWs = cfg?.agents?.defaults?.workspace ?? cfg?.agents?.default?.workspace; - } catch { /* ignore */ } - if (!defaultWs) { - // Fallback: use workspace of first existing agent - try { - const existingAgents = await ua.listAgents(); - defaultWs = existingAgents.find((a) => a.workspace)?.workspace ?? undefined; - } catch { /* ignore */ } + workspace = cfg?.agents?.defaults?.workspace ?? cfg?.agents?.default?.workspace; + } catch { + // ignore and fall back to existing agents } - if (defaultWs) command.push("--workspace", defaultWs); + + try { + const existingAgents = await ua.listAgents(); + const absoluteWorkspace = existingAgents.find( + (agent) => agent.workspace && !agent.workspace.startsWith("~"), + )?.workspace; + if (!workspace || workspace.startsWith("~")) { + workspace = absoluteWorkspace ?? workspace; + } + } catch { + // ignore and surface a dedicated error below if still unresolved + } + + if (!workspace) { + throw new Error("OpenClaw default workspace could not be resolved for non-interactive agent creation."); + } + + const command: string[] = ["openclaw", "agents", "add", id, "--non-interactive", "--workspace", workspace]; + if (modelValue) { + command.push("--model", modelValue); + } + await ua.queueCommand(`Create agent: ${id}`, command); + } else { + await ua.createAgent(id, modelValue); } - await ua.queueCommand(`Create agent: ${id}`, command); onOpenChange(false); const result: CreateAgentResult = { agentId: id }; - if (persona.trim()) result.persona = persona.trim(); + if (allowPersona && persona.trim()) result.persona = persona.trim(); reset(); onCreated(result); } catch (e) { @@ -157,51 +158,16 @@ export function CreateAgentDialog({ -
- { - const val = checked === true; - setIndependent(val); - if (!val) { - setDisplayName(""); - setEmoji(""); - setPersona(""); - } - }} - /> - -
- {independent && ( - <> -
- - setDisplayName(e.target.value)} - /> -
-
- - setEmoji(e.target.value)} - className="w-20" - /> -
-
- -