From 44736749eb0c87832aeb9c762e88ea54a4bcc571 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 00:33:02 -0700 Subject: [PATCH] feat(skills): custodian-only skill library (first wave) (#126186) * feat(skills): add custodian-only skill library * docs: document custodian skill library * refactor(skills): make custodian skills concrete and non-interactive Replace docs-link-first playbooks with verified openclaw config/message/infer one-liners; drop interactive onboard references; encode the in-session config-write policy boundary (models.*/secrets.* via trusted shell). * fix(skills): corrections from live A/B testing of custodian skills --agent required for models list/auth list in multi-agent rosters; drop hanging channels capabilities probe; telegram target is chatId; roster-safe prove via agent turn (infer model run has no --agent and dead-ends multi-agent setups); note expected not-found on pre-setup config get. * fix(skills): front-load harness plugin check in add-model-provider Gather A/B timing showed the codex plugin dependency surfacing mid-Prove, at the most expensive point (approval gate + turn boundary). Checking and remediating during Gather removes the stall. * fix(skills): keep status inventory unfiltered while scoping custodian source buildWorkspaceSkillStatus forwarding agentId activated the loader's agent allowlist filter, dropping excluded skills from the workshop's status view (collection-review regression on CI). New closed agentSkillFilter mode lets agentId scope custodian-source discovery without filtering the entry list, per the documented status invariant. --- .github/labeler.yml | 10 ++ custodian-skills/add-model-provider/SKILL.md | 72 ++++++++++ custodian-skills/cloud-image-bake/SKILL.md | 71 ++++++++++ custodian-skills/configure-channel/SKILL.md | 67 ++++++++++ custodian-skills/diagnose-gateway/SKILL.md | 56 ++++++++ docs/docs.json | 1 + docs/tools/custodian-skills.md | 73 ++++++++++ docs/tools/skills-config.md | 6 +- docs/tools/skills.md | 21 +-- package.json | 1 + src/skills/discovery/skill-index.ts | 1 + src/skills/discovery/status.ts | 4 + src/skills/loading/config.ts | 2 +- src/skills/loading/source.ts | 2 +- src/skills/loading/workspace-skill-loader.ts | 34 ++++- .../loading/workspace-skill-snapshot.test.ts | 125 ++++++++++++++++++ 16 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 custodian-skills/add-model-provider/SKILL.md create mode 100644 custodian-skills/cloud-image-bake/SKILL.md create mode 100644 custodian-skills/configure-channel/SKILL.md create mode 100644 custodian-skills/diagnose-gateway/SKILL.md create mode 100644 docs/tools/custodian-skills.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 6c51ba27d798..2564018adbd5 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -285,6 +285,16 @@ - any-glob-to-any-file: - "docs/**" +"r: skill": + - changed-files: + - any-glob-to-any-file: + - "custodian-skills/**" + - "skills/**" + - "src/skills/**" + - "docs/tools/custodian-skills.md" + - "docs/tools/skills-config.md" + - "docs/tools/skills.md" + "cli": - changed-files: - any-glob-to-any-file: diff --git a/custodian-skills/add-model-provider/SKILL.md b/custodian-skills/add-model-provider/SKILL.md new file mode 100644 index 000000000000..d227751561c7 --- /dev/null +++ b/custodian-skills/add-model-provider/SKILL.md @@ -0,0 +1,72 @@ +--- +name: add-model-provider +description: Add and live-prove a model provider with non-interactive config one-liners, without exposing credentials. +--- + +# Add a model provider + +Never print or persist secret values; credentials enter config only as SecretRefs (env or file source). Never hand-edit config files on disk — every mutation goes through `openclaw config` so it is validated and audited. Every run ends with the observable Prove result or an exact explanation of why it could not be proven. + +In-session `config_set`/`config_set_ref` tool actions are policy-blocked for `models.*` and `secrets.*`; use the trusted shell (`exec`) for the commands below. `set_default_model` is the one in-session action allowed to change the default route — it live-tests before saving. + +## Gather + +``` +openclaw config get models --json # "Config path not found" is normal before first setup +openclaw models list --agent # --agent is required in multi-agent rosters +openclaw models auth list --agent +openclaw config schema --json | jq '.properties.models' # confirm exact provider paths before writing +openclaw plugins list # OpenAI routes need the codex harness plugin; enable/install NOW, not mid-proof +``` + +If the harness plugin for the target provider is missing or disabled, remediate here (`openclaw plugins enable codex` or `openclaw plugins install @openclaw/codex`) so the Prove step does not stall on it later; plugin enable is picked up by gateway hot-reload. + +Decide the auth contract: API-key providers take a SecretRef on `models.providers..apiKey`; subscription/OAuth providers (ChatGPT/Codex, Claude subscriptions) use `openclaw models auth login --provider ` instead and must not be given an API key path. + +## Mutate + +API-key example (OpenAI), key staged by the operator in a `0600` file — validate first with `--dry-run`, then write: + +``` +openclaw config set secrets.providers.openai_key_file --provider-source file --provider-path /path/to/openai.key --provider-mode singleValue --dry-run +openclaw config set secrets.providers.openai_key_file --provider-source file --provider-path /path/to/openai.key --provider-mode singleValue +openclaw config set models.providers.openai.apiKey --ref-provider openai_key_file --ref-source file --ref-id value +``` + +Env-var alternative when the gateway process env carries the key: + +``` +openclaw config set models.providers.openai.apiKey --ref-provider default --ref-source env --ref-id OPENAI_API_KEY +``` + +To change a default model, use the in-session `set_default_model` action (with `agentId` for a non-default agent); it live-tests the route before saving. Do not change defaults with raw config writes. + +## Repair + +``` +openclaw doctor --non-interactive +``` + +If it reports a config repair, get approval, run `openclaw doctor --fix --non-interactive`, then re-run the Gather reads. + +## Prove + +Roster-safe probe (works in every setup; use your own agent id or any configured agent): + +``` +openclaw agent --agent --model openai/gpt-5.4 -m "Reply with exactly: PROVIDER-PROOF-OK" +``` + +Single-agent installs can use the lighter completion probe instead — it has no `--agent` flag and fails with "no explicit owner" on multi-agent rosters, so do not retry it there: + +``` +openclaw infer model run --gateway --model openai/gpt-5.4 --prompt "Reply with exactly: PROVIDER-PROOF-OK" +``` + +Expect the exact probe string; record model id and wall time. Known dependency: OpenAI routes need the codex harness plugin at runtime — if the probe reports the runtime unavailable, run `openclaw plugins install @openclaw/codex` and restart the gateway, then re-probe. + +## Report + +State the provider added, the SecretRef path written (never the value), the probe result with model id and latency, and whether the default model changed. If the probe failed, report the exact error and the next command to try. + +Further reference: https://docs.openclaw.ai/providers/models and https://docs.openclaw.ai/providers/openai diff --git a/custodian-skills/cloud-image-bake/SKILL.md b/custodian-skills/cloud-image-bake/SKILL.md new file mode 100644 index 000000000000..96a4409363e5 --- /dev/null +++ b/custodian-skills/cloud-image-bake/SKILL.md @@ -0,0 +1,71 @@ +--- +name: cloud-image-bake +description: Bake, select, prove, and safely retire a Cloud Worker image with crabbox and config one-liners. +--- + +# Bake a Cloud Worker image + +Never print or persist secret values; provider credentials stay in their stores. Never hand-edit config files on disk — profile changes go through `openclaw config`. Every run ends with the observable Prove result or an exact explanation of why it could not be proven. Snapshots are cheap; unmanaged snapshot sprawl is not. Never delete a provider image without hard operator confirmation. + +## Gather + +``` +openclaw config get cloudWorkers --json +crabbox config show --json +crabbox doctor --provider --json +crabbox checkpoint list --json +``` + +Record the current provider, class, image selection, setup command, and the id of the image being superseded. Confirm the requested tooling and a secret-free bake source. + +## Mutate + +Lease from the current profile, install and smoke-test the tooling: + +``` +crabbox warmup --provider --class --keep --timing-json +crabbox run --provider --id --no-sync -- bash -lc ' && --version' +``` + +Snapshot per backend: + +- AWS: `crabbox checkpoint create --provider aws --id --mode native --strategy image --wait`, inspect it, then `crabbox image promote ` with the matching scope. AWS image selection is owned by the promote catalog. +- Hetzner: `hcloud image create --type snapshot --server --description `; there is no crabbox create/promote lifecycle for Hetzner yet, so record the snapshot id explicitly. +- Firecracker: rebuild and republish the rootfs template through the host's template pipeline; do not snapshot a running microVM as a substitute. + +Point the profile at the new selection only through validated config writes — confirm the exact key first, dry-run, then write (example for a backend whose settings carry an image field): + +``` +openclaw config schema --json | jq '.properties.cloudWorkers' +openclaw config set cloudWorkers.profiles..settings. "" --dry-run +openclaw config set cloudWorkers.profiles..settings. "" +``` + +The bundled crabbox profile currently has no `image` settings key — AWS selection lives in `crabbox image promote`; never invent a config field. Preserve the old image until proof passes. + +## Repair + +``` +openclaw doctor --non-interactive +crabbox doctor --provider --json +``` + +Apply `openclaw doctor --fix --non-interactive` only after approval, then re-read the profile and provider inventory. + +## Prove + +Lease once from the new image and verify the baked tooling is present and fast: + +``` +crabbox warmup --provider --class --timing-json +crabbox run --provider --id --no-sync -- bash -lc ' --version' +crabbox stop --provider --id +``` + +Record warmup total and compare against the pre-bake timing. Then confirm the OpenClaw path end to end: dispatch one session to the profile from a client (Cloud destination) and verify the placement reaches active. If any step fails, roll back the image selection and report the exact blocker. + +## Report + +Report the profile, backend, new and previous image ids, tooling smoke result, timed warmup before/after, and rollback state. Only after successful proof, show the exact deletion target and get hard operator confirmation, then delete only that superseded snapshot (`crabbox image delete ` or `hcloud image delete `) and verify it is gone. Without confirmation, leave it intact and report cleanup pending. + +Further reference: https://docs.openclaw.ai/gateway/cloud-workers diff --git a/custodian-skills/configure-channel/SKILL.md b/custodian-skills/configure-channel/SKILL.md new file mode 100644 index 000000000000..1d35f133b3f7 --- /dev/null +++ b/custodian-skills/configure-channel/SKILL.md @@ -0,0 +1,67 @@ +--- +name: configure-channel +description: Configure and prove a chat channel with non-interactive one-liners; secrets only as SecretRefs. +--- + +# Configure a channel + +Never print or persist secret values; channel tokens enter config only as SecretRefs, or through the in-session `connect_channel` flow where the operator types the secret into a masked prompt. Never hand-edit config files on disk. Every run ends with the observable Prove result or an exact explanation of why it could not be proven. + +## Gather + +``` +openclaw channels list --all +openclaw channels status +openclaw config get channels --json # "Config path not found" is normal before first setup +``` + +Confirm the exact config path before writing — key names differ per channel (`channels.telegram.botToken`, `channels.discord.token`, ...): + +``` +openclaw config schema --json | jq '.properties.channels.properties.telegram' +``` + +## Mutate + +Preferred shell path — token staged as an env var on the gateway process or in a `0600` file, wired as a SecretRef (Telegram example): + +``` +openclaw config set channels.telegram.botToken --ref-provider default --ref-source env --ref-id TELEGRAM_BOT_TOKEN +openclaw config set channels.telegram.allowFrom '["+15555550123"]' --strict-json +``` + +Multi-field changes in one validated write: + +``` +openclaw config patch --stdin <<'JSON' +{ channels: { telegram: { enabled: true, groupPolicy: "allowlist" } } } +JSON +``` + +In-session alternative: call the `connect_channel` tool action with the channel id — the operator enters the token in a masked prompt, never in chat. Avoid `openclaw channels add --token `: it puts the secret in argv and process listings. + +## Repair + +``` +openclaw doctor --non-interactive +openclaw channels status --deep +``` + +Apply `openclaw doctor --fix --non-interactive` only after approval, then re-check status. + +## Prove + +Send one real, clearly labeled test message and confirm delivery from the command result (use `--dry-run` first to inspect the payload): + +``` +openclaw message send --channel telegram --target --message "OpenClaw channel test — please ignore" --dry-run +openclaw message send --channel telegram --target --message "OpenClaw channel test — please ignore" +``` + +If sending fails, report the exact account, permission, destination, or network blocker without exposing credentials. + +## Report + +State the channel and account changed, the exact config paths written (never values), the test destination, and the observed delivery result. List any remaining operator action. + +Further reference: https://docs.openclaw.ai/channels/telegram (and the matching page for other channels) diff --git a/custodian-skills/diagnose-gateway/SKILL.md b/custodian-skills/diagnose-gateway/SKILL.md new file mode 100644 index 000000000000..bd82932ffef0 --- /dev/null +++ b/custodian-skills/diagnose-gateway/SKILL.md @@ -0,0 +1,56 @@ +--- +name: diagnose-gateway +description: Diagnose Gateway, config, secrets, channels, and port failures with read-only one-liners. +--- + +# Diagnose the Gateway + +This playbook is read-only: no config writes, no service restarts, no `doctor --fix`, no killing listeners. Never print secret values; report only redacted SecretRef owner state. Every run ends with the observable Prove result or an exact explanation of why it could not be proven. + +## Gather + +``` +openclaw doctor --non-interactive +openclaw gateway status --deep +openclaw config validate +openclaw channels status +openclaw models status +openclaw channels logs --channel +``` + +On managed installs, bounded recent logs: `./scripts/clawlog.sh` (repo checkout) or the log path printed at gateway startup (`/tmp/openclaw/openclaw-.log` by default). + +Check these signatures without guessing: + +- invalid config or schema errors (`config validate` names the exact key and line); +- degraded SecretRef owners — report the owner, never ids or values; +- expired or rejected channel authentication (`channels status` per account); +- `EADDRINUSE`, a second gateway listener, or service/config port mismatch (`lsof -nP -iTCP: -sTCP:LISTEN`); +- gateway crash loops: read the last startup stack in the gateway log; a schema-valid config that still crashes startup is a bug — capture the stack and report it. + +Correlate timestamps and identify the first owner-boundary failure. + +## Mutate + +Nothing. This skill changes no state. + +## Repair + +Translate each finding into the next action, naming the responsible skill when one exists: `configure-channel`, `add-model-provider`, or `cloud-image-bake`. Recommend `openclaw doctor --fix --non-interactive` only as a separately approved step. + +## Prove + +Repeat the smallest read-only probe that exposes the condition and record its output, for example: + +``` +openclaw gateway status --deep +openclaw channels status --deep +``` + +If access, logs, or the gateway are unavailable, report that exact blocker rather than declaring a cause. + +## Report + +Findings in causal order with evidence for each; current gateway/config/SecretRef/channel/port state; one recommended next skill or operator action. State explicitly that nothing was changed. + +Further reference: https://docs.openclaw.ai/gateway/troubleshooting diff --git a/docs/docs.json b/docs/docs.json index 96d9edbaf2f3..e004d1866cfd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1362,6 +1362,7 @@ "group": "Skills", "pages": [ "tools/skills", + "tools/custodian-skills", "tools/skill-workshop", "tools/self-learning", "tools/creating-skills", diff --git a/docs/tools/custodian-skills.md b/docs/tools/custodian-skills.md new file mode 100644 index 000000000000..5bfcf29e10e7 --- /dev/null +++ b/docs/tools/custodian-skills.md @@ -0,0 +1,73 @@ +--- +title: "Custodian skills" +sidebarTitle: "Custodian skills" +summary: "Release-versioned operational skills that only the configured Custodian agent can discover and use." +read_when: + - Configuring or extending the Custodian agent + - Reviewing agent-only skill loading + - Planning operational skill coverage +--- + +Custodian skills are release-versioned operational playbooks shipped with OpenClaw. They live under `custodian-skills/` in the package and load at the bundled-skill precedence tier, but only for the agent resolved by `agents.defaults.systemAgent.agentId`. + +When that setting is absent, OpenClaw uses the existing system-agent fallback: the sole configured agent, or legacy `main` when no explicit agent roster exists. If several agents are configured and no system agent is selected, no agent receives the library. For every other agent, Custodian skills are absent from discovery, snapshots, slash-command catalogs, sandbox sync, and the model-facing skills prompt. + +Normal skill controls still apply. `skills.entries..enabled: false` disables an individual Custodian skill, and agent skill allowlists can narrow the final set. See [Skills config](/tools/skills-config). + +## Workflow contract + +Every shipped Custodian skill uses the same five sections in this order: + +1. **Gather** reads redacted current config and probes live state. +2. **Mutate** uses validated non-interactive writes — `openclaw config set` / `openclaw config patch` from a trusted shell, or the in-session Custodian tool actions where policy allows — never a direct file edit. +3. **Repair** runs `openclaw doctor` and separates diagnosis from any approved repair. +4. **Prove** exercises one live end-to-end outcome. +5. **Report** records what changed, what was observed, and what remains. + +All five-section playbooks keep secret values out of prompts, logs, and files. Credentials use SecretRefs or credential stores. A workflow never claims success without its Prove outcome; it reports the exact blocker when live proof is unavailable. + +## First wave + +| Skill | Outcome | +| -------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `configure-channel` | Configure and send a confirmed test message through a channel family such as Discord, Slack, Telegram, or WhatsApp. | +| `add-model-provider` | Configure API-key or subscription/OAuth provider access and run one live Gateway inference. | +| `diagnose-gateway` | Perform read-only Gateway, config, SecretRef, channel-auth, log, and port triage. | +| `cloud-image-bake` | Bake a Cloud Worker image, prove it with a timed dispatch, and safely retire the superseded snapshot. | + +## Roadmap catalog + +The following catalog documents intended later tiers. These names are roadmap entries, not bundled skills or promises of current behavior. + +### Tier 2: common operations + +- `configure-search`: configure and live-prove a search provider. +- `create-agent`: create an agent, verify its workspace, and prove one turn. +- `manage-plugin`: install, configure, verify, or remove an approved plugin. +- `rotate-credential`: rotate one supported credential through its owning store and prove the consumer. +- `upgrade-openclaw`: stage an upgrade, run health checks, and verify rollback readiness. + +### Tier 3: advanced operations + +- `fleet-rollout`: roll out one verified config or release across managed Gateways. +- `incident-response`: collect redacted evidence, contain an incident, and verify recovery. +- `migrate-gateway`: move a Gateway while preserving explicit state and identity contracts. +- `release-validation`: run release-track package, install, and live behavior proof. +- `restore-backup`: restore into an isolated target, validate state, and cut over deliberately. + +## Add an operator skill + +Put local additions in the configured Custodian agent's workspace, not in the release-owned package directory: + +```text +/skills//SKILL.md +``` + +Workspace skills already have higher precedence than the bundled tier and are scoped to that agent's workspace. Follow the same Gather → Mutate → Repair → Prove → Report contract, keep the description short, and start a new session after changing the skill. See [Creating skills](/tools/creating-skills) for the full format. + +## Related + +- [Skills](/tools/skills) +- [Skills config](/tools/skills-config) +- [Cloud Workers](/gateway/cloud-workers) +- [Gateway troubleshooting](/gateway/troubleshooting) diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 0356d2254449..e22509900601 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -482,10 +482,14 @@ workspace/skills (highest) workspace/.agents/skills ~/.agents/skills ~/.openclaw/skills -bundled skills +bundled + Custodian skills skills.load.extraDirs (lowest) ``` +Custodian skills share bundled precedence but load only for the agent selected +by `agents.defaults.systemAgent.agentId` (or the existing sole-agent fallback). +See [Custodian skills](/tools/custodian-skills). + Changes to skills and config take effect on the next new session when the watcher is enabled, or on the next agent turn when the watcher detects a change. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 397c423453ef..43df393ea6bd 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -34,14 +34,15 @@ binary presence. OpenClaw loads from these sources, **highest precedence first**. When the same skill name appears in multiple places, the highest source wins. -| Priority | Source | Path | -| ----------- | ---------------------- | --------------------------------------- | -| 1 — highest | Workspace skills | `/skills` | -| 2 | Project agent skills | `/.agents/skills` | -| 3 | Personal agent skills | `~/.agents/skills` (default state only) | -| 4 | Managed / local skills | `/skills` | -| 5 | Bundled skills | shipped with the install | -| 6 — lowest | Extra directories | `skills.load.extraDirs` + plugin skills | +| Priority | Source | Path | +| ----------- | ---------------------- | ---------------------------------------- | +| 1 — highest | Workspace skills | `/skills` | +| 2 | Project agent skills | `/.agents/skills` | +| 3 | Personal agent skills | `~/.agents/skills` (default state only) | +| 4 | Managed / local skills | `/skills` | +| 5 | Bundled skills | shipped with the install | +| 5 | Custodian skills | shipped; configured Custodian agent only | +| 6 — lowest | Extra directories | `skills.load.extraDirs` + plugin skills | Skill roots support grouped layouts. OpenClaw discovers a skill whenever `SKILL.md` appears anywhere under a configured root (up to 6 levels deep): @@ -55,6 +56,10 @@ The folder path is for organization only. The skill's name and slash command come from the `name` frontmatter field (or the directory name when `name` is missing). Agent allowlists (below) also match on this `name`. +The release-versioned [Custodian skill library](/tools/custodian-skills) shares +the bundled precedence tier but is absent for every agent except the configured +system/Custodian agent. + Codex CLI's native `$CODEX_HOME/skills` directory is **not** an OpenClaw skill root. Use `openclaw migrate plan codex` to inventory those skills, then diff --git a/package.json b/package.json index cbf8825145e2..8855cdaa3d2f 100644 --- a/package.json +++ b/package.json @@ -372,6 +372,7 @@ "scripts/lib/tsx-cli-shim.mjs", "patches/", "skills/", + "custodian-skills/", "scripts/prepare-git-hooks.mjs", "scripts/preinstall-package-manager-warning.mjs", "scripts/lib/official-external-channel-catalog.json", diff --git a/src/skills/discovery/skill-index.ts b/src/skills/discovery/skill-index.ts index b106e9589106..490285fec311 100644 --- a/src/skills/discovery/skill-index.ts +++ b/src/skills/discovery/skill-index.ts @@ -92,6 +92,7 @@ function createSkillIndexEntry( source, bundled: source === "openclaw-bundled" || + source === "openclaw-custodian" || (source === "unknown" && opts?.bundledNames?.has(name) === true), agentAllowed: agentSkillSet === undefined || agentSkillSet.has(name), runtimeVisible: isSkillRuntimeVisible(entry), diff --git a/src/skills/discovery/status.ts b/src/skills/discovery/status.ts index 6a99a7bdc607..f74c7f158401 100644 --- a/src/skills/discovery/status.ts +++ b/src/skills/discovery/status.ts @@ -363,6 +363,10 @@ export function buildWorkspaceSkillStatus( opts?.entries ?? loadWorkspaceSkills(workspaceDir, { config: opts?.config, + // agentId scopes custodian-source discovery only; the "ignore" mode + // keeps the entry list unfiltered per the invariant above. + agentId: opts?.agentId, + agentSkillFilter: "ignore", managedSkillsDir, bundledSkillsDir: bundledContext.dir, includeArchived: true, diff --git a/src/skills/loading/config.ts b/src/skills/loading/config.ts index 6f5cf74ae66b..8279b4a1c0dc 100644 --- a/src/skills/loading/config.ts +++ b/src/skills/loading/config.ts @@ -100,7 +100,7 @@ function normalizeAllowlist(input: unknown): ReadonlySet | undefined { return normalized.length > 0 ? new Set(normalized) : undefined; } -const BUNDLED_SOURCES = new Set(["openclaw-bundled"]); +const BUNDLED_SOURCES = new Set(["openclaw-bundled", "openclaw-custodian"]); function isBundledSkill(entry: SkillEntry): boolean { return BUNDLED_SOURCES.has(resolveSkillSource(entry.skill)); diff --git a/src/skills/loading/source.ts b/src/skills/loading/source.ts index 8682ccb43970..bea843c3765a 100644 --- a/src/skills/loading/source.ts +++ b/src/skills/loading/source.ts @@ -22,7 +22,7 @@ export function resolveSkillSource(skill: Skill): string { export function resolveSkillTelemetrySourceValue(value: unknown): SkillTelemetrySource { const source = normalizeOptionalString(value) ?? ""; - if (source === "bundled" || source === "openclaw-bundled") { + if (source === "bundled" || source === "openclaw-bundled" || source === "openclaw-custodian") { return "bundled"; } if ( diff --git a/src/skills/loading/workspace-skill-loader.ts b/src/skills/loading/workspace-skill-loader.ts index f6d2ba7b5da1..d343930f4b89 100644 --- a/src/skills/loading/workspace-skill-loader.ts +++ b/src/skills/loading/workspace-skill-loader.ts @@ -3,12 +3,14 @@ import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { tryResolveSystemAgentTargetAgentId } from "../../agents/agent-scope-config.js"; import { canonicalizePath } from "../../agents/utils/paths.js"; import { isDefaultStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { CONFIG_DIR, resolveUserPath } from "../../utils.js"; import { isSessionSkillEnabled, @@ -49,6 +51,7 @@ import { import { resolveAllowedSkillSymlinkTargetRealPaths, tryRealpath } from "./symlink-targets.js"; const skillsLogger = createSubsystemLogger("skills"); +const CUSTODIAN_SKILLS_DIR_NAME = "custodian-skills"; const SKILL_SOURCE_ORIGIN_RELATIVE_PATH = path.join(".openclaw", "source-origin.json"); const MAX_SKILL_SOURCE_ORIGIN_BYTES = 16 * 1024; @@ -72,6 +75,12 @@ type WorkspaceSkillLoadOptions = { skillFilter?: string[]; skillOverrides?: Record; agentId?: string; + /** + * "ignore" keeps agentId scoping source discovery (custodian skills) without + * activating the agent allowlist filter — status/inventory views need the + * full entry list so excluded skills stay present-but-marked. + */ + agentSkillFilter?: "apply" | "ignore"; eligibility?: SkillEligibilityContext; workspaceOnly?: boolean; includeArchived?: boolean; @@ -368,6 +377,19 @@ function loadSkillEntries( const bundledSkills = bundledSkillsDir ? loadSkills({ dir: bundledSkillsDir, source: "openclaw-bundled" }) : []; + const custodianAgentId = opts?.config + ? tryResolveSystemAgentTargetAgentId(opts.config) + : undefined; + const custodianSkillsDir = + bundledSkillsDir && + opts?.agentId && + custodianAgentId && + normalizeAgentId(opts.agentId) === custodianAgentId + ? path.join(path.dirname(bundledSkillsDir), CUSTODIAN_SKILLS_DIR_NAME) + : undefined; + const custodianSkills = custodianSkillsDir + ? loadSkills({ dir: custodianSkillsDir, source: "openclaw-custodian" }) + : []; const extraSkills = [ ...mergedExtraDirs.flatMap((dir) => loadSkills({ dir: resolveUserPath(dir), source: "openclaw-extra" }), @@ -411,7 +433,14 @@ function loadSkillEntries( for (const record of extraSkills) { mergeRecord(record); } - for (const record of bundledSkills) { + // Custodian skills share bundled precedence. Sort the tier so source traversal + // remains deterministic even if a package accidentally ships a duplicate name. + const bundledTierSkills = [...bundledSkills, ...custodianSkills].toSorted( + (left, right) => + left.skill.name.localeCompare(right.skill.name, "en") || + left.skill.source.localeCompare(right.skill.source, "en"), + ); + for (const record of bundledTierSkills) { mergeRecord(record); } for (const record of managedSkills) { @@ -469,12 +498,13 @@ function filterArchivedSkillEntries(entries: SkillEntry[]): SkillEntry[] { function resolveEffectiveWorkspaceSkillFilter(opts?: { config?: OpenClawConfig; agentId?: string; + agentSkillFilter?: "apply" | "ignore"; skillFilter?: string[]; }): string[] | undefined { if (opts?.skillFilter !== undefined) { return normalizeSkillFilter(opts.skillFilter); } - if (!opts?.config || !opts.agentId) { + if (opts?.agentSkillFilter === "ignore" || !opts?.config || !opts.agentId) { return undefined; } return resolveEffectiveAgentSkillFilter(opts.config, opts.agentId); diff --git a/src/skills/loading/workspace-skill-snapshot.test.ts b/src/skills/loading/workspace-skill-snapshot.test.ts index 3f72c274150e..34cdb5d95704 100644 --- a/src/skills/loading/workspace-skill-snapshot.test.ts +++ b/src/skills/loading/workspace-skill-snapshot.test.ts @@ -2,9 +2,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { withEnv, withPathResolutionEnv } from "../../test-utils/env.js"; import { createFixtureSuite } from "../../test-utils/fixture-suite.js"; import { createTempHomeEnv, type TempHomeEnv } from "../../test-utils/temp-home.js"; +import { buildWorkspaceSkillStatus } from "../discovery/status.js"; import { resolveEmbeddedRunSkillEntries } from "../runtime/embedded-run-entries.js"; import { resolveReusableWorkspaceSkillSnapshot } from "../runtime/session-snapshot.js"; import { writeSkill, writeWorkspaceSkills } from "../test-support/e2e-test-helpers.js"; @@ -67,6 +69,34 @@ function buildSnapshot(workspaceDir: string, options?: Parameters { + for (const name of CUSTODIAN_SKILL_NAMES) { + await writeSkill({ + dir: path.join(workspaceDir, "custodian-skills", name), + name, + description: `Custodian ${name}`, + }); + } +} + +function buildAgentSnapshot(params: { + workspaceDir: string; + config: OpenClawConfig; + agentId: string; +}) { + return buildSnapshot(params.workspaceDir, { + config: params.config, + agentId: params.agentId, + }); +} + async function cloneTemplateDir(templateDir: string, prefix: string): Promise { const cloned = await fixtureSuite.createCaseDir(prefix); await fs.cp(templateDir, cloned, { recursive: true }); @@ -121,6 +151,101 @@ function expectSnapshotNamesAndPrompt( } describe("buildSkillSnapshot", () => { + it("keeps custodian skills absent from every non-custodian discovery surface", async () => { + const workspaceDir = await fixtureSuite.createCaseDir("custodian-gate"); + await writeCustodianSkillFixture(workspaceDir); + const config: OpenClawConfig = { + agents: { + defaults: { systemAgent: { agentId: "ops" } }, + entries: { ops: {}, writer: {} }, + }, + }; + + const firstCustodianSnapshot = buildAgentSnapshot({ workspaceDir, config, agentId: "ops" }); + const secondCustodianSnapshot = buildAgentSnapshot({ workspaceDir, config, agentId: "ops" }); + const writerSnapshot = buildAgentSnapshot({ workspaceDir, config, agentId: "writer" }); + const custodianStatus = buildWorkspaceSkillStatus(workspaceDir, { + config, + agentId: "ops", + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + const writerStatus = buildWorkspaceSkillStatus(workspaceDir, { + config, + agentId: "writer", + managedSkillsDir: path.join(workspaceDir, ".managed"), + }); + + expect(firstCustodianSnapshot.skills.map((skill) => skill.name)).toEqual(CUSTODIAN_SKILL_NAMES); + expect(firstCustodianSnapshot.resolvedSkills?.map((skill) => skill.source)).toEqual( + CUSTODIAN_SKILL_NAMES.map(() => "openclaw-custodian"), + ); + expect(secondCustodianSnapshot.skills).toEqual(firstCustodianSnapshot.skills); + expect(secondCustodianSnapshot.prompt).toBe(firstCustodianSnapshot.prompt); + expect(writerSnapshot.skills).toEqual([]); + expect(writerSnapshot.prompt).toBe(""); + expect( + custodianStatus.skills + .filter((skill) => skill.source === "openclaw-custodian") + .map((skill) => skill.name), + ).toEqual(CUSTODIAN_SKILL_NAMES); + expect(writerStatus.skills.filter((skill) => skill.source === "openclaw-custodian")).toEqual( + [], + ); + }); + + it("mirrors the system-agent resolver fallback when no owner is configured", async () => { + const workspaceDir = await fixtureSuite.createCaseDir("custodian-owner-fallback"); + await writeCustodianSkillFixture(workspaceDir); + + const soleAgentConfig: OpenClawConfig = { + agents: { entries: { caretaker: {} } }, + }; + const ambiguousConfig: OpenClawConfig = { + agents: { entries: { ops: {}, writer: {} } }, + }; + const soleSnapshot = buildAgentSnapshot({ + workspaceDir, + config: soleAgentConfig, + agentId: "caretaker", + }); + const mainSnapshot = buildAgentSnapshot({ workspaceDir, config: {}, agentId: "main" }); + const ambiguousSnapshot = buildAgentSnapshot({ + workspaceDir, + config: ambiguousConfig, + agentId: "ops", + }); + + expect(soleSnapshot.skills.map((skill) => skill.name)).toEqual(CUSTODIAN_SKILL_NAMES); + expect(mainSnapshot.skills.map((skill) => skill.name)).toEqual(CUSTODIAN_SKILL_NAMES); + expect(ambiguousSnapshot.skills).toEqual([]); + expect(ambiguousSnapshot.prompt).toBe(""); + }); + + it("applies per-skill disabled overrides to custodian skills", async () => { + const workspaceDir = await fixtureSuite.createCaseDir("custodian-disabled"); + await writeCustodianSkillFixture(workspaceDir); + const config: OpenClawConfig = { + agents: { + defaults: { systemAgent: { agentId: "ops" } }, + entries: { ops: {} }, + }, + skills: { + entries: { + "cloud-image-bake": { enabled: false }, + }, + }, + }; + + const snapshot = buildAgentSnapshot({ workspaceDir, config, agentId: "ops" }); + + expect(snapshot.skills.map((skill) => skill.name)).toEqual([ + "add-model-provider", + "configure-channel", + "diagnose-gateway", + ]); + expect(snapshot.prompt).not.toContain("cloud-image-bake"); + }); + it("orders agent skills before execution skills with lexical order inside each root", async () => { const { snapshot } = await createMultiRootFixture();