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.
This commit is contained in:
Peter Steinberger
2026-08-19 00:33:02 -07:00
committed by GitHub
parent 617bc7ebdd
commit 44736749eb
16 changed files with 533 additions and 13 deletions
+10
View File
@@ -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:
@@ -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 <agentId> # --agent is required in multi-agent rosters
openclaw models auth list --agent <agentId>
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.<id>.apiKey`; subscription/OAuth providers (ChatGPT/Codex, Claude subscriptions) use `openclaw models auth login --provider <id>` 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 <agentId> --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
@@ -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 <backend> --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 <backend> --class <class> --keep --timing-json
crabbox run --provider <backend> --id <lease> --no-sync -- bash -lc '<install commands> && <tool> --version'
```
Snapshot per backend:
- AWS: `crabbox checkpoint create --provider aws --id <lease> --mode native --strategy image --wait`, inspect it, then `crabbox image promote <ami-id>` with the matching scope. AWS image selection is owned by the promote catalog.
- Hetzner: `hcloud image create --type snapshot --server <server-id> --description <name>`; 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.<profile>.settings.<imageKey> "<image-id>" --dry-run
openclaw config set cloudWorkers.profiles.<profile>.settings.<imageKey> "<image-id>"
```
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 <backend> --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 <backend> --class <class> --timing-json
crabbox run --provider <backend> --id <lease> --no-sync -- bash -lc '<tool> --version'
crabbox stop --provider <backend> --id <lease>
```
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 <id>` or `hcloud image delete <id>`) and verify it is gone. Without confirmation, leave it intact and report cleanup pending.
Further reference: https://docs.openclaw.ai/gateway/cloud-workers
@@ -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 <value>`: 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 <chatId> --message "OpenClaw channel test — please ignore" --dry-run
openclaw message send --channel telegram --target <chatId> --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)
@@ -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 <id>
```
On managed installs, bounded recent logs: `./scripts/clawlog.sh` (repo checkout) or the log path printed at gateway startup (`/tmp/openclaw/openclaw-<date>.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:<port> -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
+1
View File
@@ -1362,6 +1362,7 @@
"group": "Skills",
"pages": [
"tools/skills",
"tools/custodian-skills",
"tools/skill-workshop",
"tools/self-learning",
"tools/creating-skills",
+73
View File
@@ -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.<name>.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
<custodian-workspace>/skills/<skill-name>/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)
+5 -1
View File
@@ -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.
+13 -8
View File
@@ -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 | `<workspace>/skills` |
| 2 | Project agent skills | `<workspace>/.agents/skills` |
| 3 | Personal agent skills | `~/.agents/skills` (default state only) |
| 4 | Managed / local skills | `<state-dir>/skills` |
| 5 | Bundled skills | shipped with the install |
| 6 — lowest | Extra directories | `skills.load.extraDirs` + plugin skills |
| Priority | Source | Path |
| ----------- | ---------------------- | ---------------------------------------- |
| 1 — highest | Workspace skills | `<workspace>/skills` |
| 2 | Project agent skills | `<workspace>/.agents/skills` |
| 3 | Personal agent skills | `~/.agents/skills` (default state only) |
| 4 | Managed / local skills | `<state-dir>/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.
<Note>
Codex CLI's native `$CODEX_HOME/skills` directory is **not** an OpenClaw
skill root. Use `openclaw migrate plan codex` to inventory those skills, then
+1
View File
@@ -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",
+1
View File
@@ -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),
+4
View File
@@ -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,
+1 -1
View File
@@ -100,7 +100,7 @@ function normalizeAllowlist(input: unknown): ReadonlySet<string> | 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));
+1 -1
View File
@@ -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 (
+32 -2
View File
@@ -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<string, boolean>;
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);
@@ -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<typeof buildSk
);
}
const CUSTODIAN_SKILL_NAMES = [
"add-model-provider",
"cloud-image-bake",
"configure-channel",
"diagnose-gateway",
] as const;
async function writeCustodianSkillFixture(workspaceDir: string): Promise<void> {
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<string> {
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();