fix: support grouped skill folders

Support grouped skill folders while keeping skill invocation flat via frontmatter names.

Includes bounded nested SKILL.md discovery, refresh/watch coverage for grouped folders, plugin symlink containment, and docs for grouped skill organization.

Verification:
- Node 24 targeted skill discovery and refresh tests passed locally.
- Docs checks passed locally and in CI.
- Autoreview clean.
- Crabbox live OpenAI proof showed nested foo/bar skills listed and visible in the agent system prompt.
- CI run 26595118581 passed.
This commit is contained in:
Peter Steinberger
2026-05-28 19:52:27 +01:00
committed by GitHub
parent 4b8c260444
commit 43e243f436
10 changed files with 1656 additions and 225 deletions

View File

@@ -65,6 +65,10 @@ OpenClaw loads skills from these locations (highest precedence first):
- Bundled (shipped with the install)
- Extra skill folders: `skills.load.extraDirs`
Skill roots can contain grouped folders such as
`<workspace>/skills/personal/foo/SKILL.md`; the skill is still exposed by its
flat frontmatter name, for example `foo`.
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
## Runtime boundaries

View File

@@ -258,6 +258,10 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
location (workspace, managed, or bundled). If no skills are eligible, the
Skills section is omitted.
The location can point at a nested skill, such as
`skills/personal/foo/SKILL.md`. Nesting is only organizational; the prompt still
uses the flat skill name from `SKILL.md` frontmatter.
Eligibility includes skill metadata gates, runtime environment/config checks,
and the effective agent skill allowlist when `agents.defaults.skills` or
`agents.list[].skills` is configured.

View File

@@ -21,6 +21,16 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
mkdir -p ~/.openclaw/workspace/skills/hello-world
```
You can group skills in subfolders when your library grows:
```bash
mkdir -p ~/.openclaw/workspace/skills/personal/hello-world
```
Group folders are only organizational. The skill is still named by
`SKILL.md` frontmatter, so `name: hello-world` is invoked as
`/hello-world`.
</Step>
<Step title="Write SKILL.md">
@@ -40,7 +50,7 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
```
Use hyphen-case with lowercase letters, digits, and hyphens for the skill
`name`. Keep the folder name and frontmatter `name` aligned.
`name`. Keep the leaf folder name and frontmatter `name` aligned.
</Step>
@@ -52,7 +62,15 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
</Step>
<Step title="Load the skill">
Start a new session so OpenClaw picks up the skill:
Verify the skill loaded:
```bash
openclaw skills list
```
OpenClaw watches nested `SKILL.md` files under skills roots. If the watcher
is disabled or you are continuing an existing session, start a new session
so the model receives the refreshed skills list:
```bash
# From chat
@@ -62,12 +80,6 @@ For how skills are loaded and prioritized, see [Skills](/tools/skills).
openclaw gateway restart
```
Verify the skill loaded:
```bash
openclaw skills list
```
</Step>
<Step title="Test it">
@@ -134,6 +146,10 @@ Once a basic skill works, these fields help make it reliable and portable:
| Bundled (shipped with OpenClaw) | Low | Global |
| `skills.load.extraDirs` | Lowest | Custom shared folders |
Each skills root can contain direct skill folders such as
`skills/hello-world/SKILL.md` or grouped folders such as
`skills/personal/hello-world/SKILL.md`.
## Related
- [Skills reference](/tools/skills) — loading, precedence, and gating rules

View File

@@ -29,6 +29,19 @@ OpenClaw loads skills from these sources, **highest precedence first**:
If a skill name conflicts, the highest source wins.
Skill roots can be organized with folders. A skill is discovered when a
`SKILL.md` appears under a configured skills root, so these are both valid:
```text
<workspace>/skills/research/SKILL.md
<workspace>/skills/personal/research/SKILL.md
```
The folder path is only for organization. The skill's visible name, slash
command, and allowlist key come from `SKILL.md` frontmatter `name` (or the skill
directory name when `name` is missing), so a nested skill with `name: research`
is still invoked as `/research`, not `/personal/research`.
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
skill roots. In Codex harness mode, local app-server launches use isolated
per-agent Codex homes, so skills in the operator's personal `~/.codex/skills`
@@ -149,9 +162,11 @@ all local agents unless agent skill allowlists narrow visibility. The separate
`clawhub` CLI also installs into `./skills` under your current working
directory (or falls back to the configured OpenClaw workspace). OpenClaw picks
that up as `<workspace>/skills` on the next session.
Configured skill roots also support one grouping level, such as
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be
kept under a shared folder without broad recursive scanning.
Configured skill roots also support grouped layouts, such as
`skills/<group>/<skill>/SKILL.md`, so related third-party skills can be kept
under shared folders without broad recursive scanning. Use flat frontmatter
names when grouping, for example `skills/imported/research/SKILL.md` with
`name: research`.
Git and local directory installs expect a `SKILL.md` at the source root. The
install slug comes from `SKILL.md` frontmatter `name` when it is a valid slug,
@@ -196,6 +211,11 @@ Prefer sandboxed runs for untrusted inputs and risky tools. See
</Warning>
- Workspace, project-agent, and extra-dir skill discovery only accepts skill roots whose resolved realpath stays inside the configured root unless `skills.load.allowSymlinkTargets` explicitly trusts a target root. Bundled skills always stay contained. Managed `~/.openclaw/skills` and personal `~/.agents/skills` roots may contain symlinked skill folders installed by ClawHub or another local skill manager, but every `SKILL.md` realpath must still stay inside its resolved skill directory.
- Nested discovery is bounded. OpenClaw scans grouped skill folders under
skills roots such as `<workspace>/skills`, `<workspace>/.agents/skills`,
`~/.agents/skills`, and `~/.openclaw/skills`, but skips hidden directories,
`node_modules`, oversized `SKILL.md` files, escaped symlinks, and suspiciously
large directory trees.
- Gateway private archive installs are off by default. When explicitly enabled,
they require a committed zip upload containing `SKILL.md` and reuse the same
archive extraction, path traversal, symlink, force, and rollback protections as
@@ -488,6 +508,10 @@ layouts where a skill root contains a symlink, for example
symlinks from local skill managers by default, but the target list is still
matched after realpath resolution and should stay narrow when configured.
The watcher covers nested `SKILL.md` files under grouped skill roots. Adding or
editing `skills/personal/foo/SKILL.md` refreshes the snapshot the same way as
editing `skills/foo/SKILL.md`.
### Remote macOS nodes (Linux gateway)
If the Gateway runs on Linux but a **macOS node** is connected with

View File

@@ -1,9 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { Model } from "../llm/types.js";
vi.mock("./agent-model-discovery.js", () => ({
normalizeDiscoveredAgentModel: (value: unknown) => value,
}));
import { appendPrioritizedDynamicLiveModels } from "./live-model-dynamic-candidates.js";
const REGISTRY = { find: () => undefined } as never;
const DYNAMIC_PROVIDER = "dynamic-test-provider";
type DynamicModelResolver = NonNullable<
Parameters<typeof appendPrioritizedDynamicLiveModels>[0]["resolveDynamicModel"]
>;
@@ -29,15 +35,15 @@ function model(provider: string, id: string): Model {
describe("appendPrioritizedDynamicLiveModels", () => {
it("materializes prioritized refs from provider dynamic model hooks", async () => {
const resolveDynamicModel: DynamicModelResolver = vi.fn((params) =>
params.context.provider === "opencode-go" && params.context.modelId === "glm-5"
? model("opencode-go", "glm-5")
params.context.provider === DYNAMIC_PROVIDER && params.context.modelId === "glm-5"
? model(DYNAMIC_PROVIDER, "glm-5")
: undefined,
);
const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined);
const config = {
models: {
providers: {
"opencode-go": {
[DYNAMIC_PROVIDER]: {
api: "openai-completions",
baseUrl: "https://configured.example/v1",
models: [],
@@ -55,56 +61,56 @@ describe("appendPrioritizedDynamicLiveModels", () => {
prepareDynamicModel,
refs: [
{ provider: "anthropic", id: "claude-sonnet-4-6" },
{ provider: "opencode-go", id: "glm-5" },
{ provider: DYNAMIC_PROVIDER, id: "glm-5" },
],
});
expect(result.added.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
"opencode-go/glm-5",
`${DYNAMIC_PROVIDER}/glm-5`,
]);
expect(result.models.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([
"anthropic/claude-sonnet-4-6",
"opencode-go/glm-5",
`${DYNAMIC_PROVIDER}/glm-5`,
]);
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
expect(prepareDynamicModel).toHaveBeenCalledWith(
expect.objectContaining({
provider: "opencode-go",
provider: DYNAMIC_PROVIDER,
context: expect.objectContaining({
agentDir: "/tmp/openclaw-agent",
modelId: "glm-5",
modelRegistry: REGISTRY,
provider: "opencode-go",
providerConfig: config.models?.providers?.["opencode-go"],
provider: DYNAMIC_PROVIDER,
providerConfig: config.models?.providers?.[DYNAMIC_PROVIDER],
}),
}),
);
expect(resolveDynamicModel).toHaveBeenCalledTimes(1);
expect(resolveDynamicModel).toHaveBeenCalledWith(
expect.objectContaining({
provider: "opencode-go",
provider: DYNAMIC_PROVIDER,
context: expect.objectContaining({
agentDir: "/tmp/openclaw-agent",
modelId: "glm-5",
modelRegistry: REGISTRY,
provider: "opencode-go",
providerConfig: config.models?.providers?.["opencode-go"],
provider: DYNAMIC_PROVIDER,
providerConfig: config.models?.providers?.[DYNAMIC_PROVIDER],
}),
}),
);
});
it("does not duplicate refs already present in the generated registry", async () => {
const resolveDynamicModel: DynamicModelResolver = vi.fn(() => model("opencode-go", "glm-5"));
const resolveDynamicModel: DynamicModelResolver = vi.fn(() => model(DYNAMIC_PROVIDER, "glm-5"));
const prepareDynamicModel: DynamicModelPreparer = vi.fn(async () => undefined);
const result = await appendPrioritizedDynamicLiveModels({
models: [model("opencode-go", "glm-5")],
models: [model(DYNAMIC_PROVIDER, "glm-5")],
agentDir: "/tmp/openclaw-agent",
modelRegistry: REGISTRY,
resolveDynamicModel,
prepareDynamicModel,
refs: [{ provider: "opencode-go", id: "glm-5" }],
refs: [{ provider: DYNAMIC_PROVIDER, id: "glm-5" }],
});
expect(result.added).toEqual([]);

View File

@@ -723,15 +723,479 @@ describe("loadWorkspaceSkillEntries", () => {
expect(names).toEqual(["valid-skill"]);
});
it("does not descend more than two levels (skills/a/b/c/SKILL.md is ignored)", async () => {
it("loads earlier grouped skills before later direct siblings hit the source cap", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "00-group", "grouped"),
name: "grouped-skill",
description: "Grouped skill before direct siblings",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "01-direct"),
name: "direct-skill",
description: "Direct sibling after grouped skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
limits: {
maxCandidatesPerRoot: 10,
maxSkillsLoadedPerSource: 1,
},
},
},
}).map((entry) => entry.skill.name);
expect(names).toEqual(["grouped-skill"]);
});
it("keeps later grouped siblings discoverable when an earlier group is noisy", async () => {
const workspaceDir = await createTempWorkspaceDir();
async function createNoisyTree(dir: string, depth: number): Promise<void> {
if (depth === 0) {
return;
}
for (const name of ["00-a", "01-b"]) {
const childDir = path.join(dir, name);
await fs.mkdir(childDir, { recursive: true });
await createNoisyTree(childDir, depth - 1);
}
}
await createNoisyTree(path.join(workspaceDir, "skills", "00-noisy"), 6);
await writeSkill({
dir: path.join(workspaceDir, "skills", "01-later", "later-skill"),
name: "later-skill",
description: "Grouped sibling after a noisy tree",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
limits: {
maxCandidatesPerRoot: 2,
maxSkillsLoadedPerSource: 10,
},
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("later-skill");
});
it("discovers deeply nested SKILL.md files within the Codex-compatible depth", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "a", "b", "c"),
name: "too-deep",
description: "Should not be discovered (depth 3)",
name: "deep-skill",
description: "Discovered through grouped folders",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).toContain("deep-skill");
});
it("discovers deeply nested skills in configured roots named skills", async () => {
const workspaceDir = await createTempWorkspaceDir();
const parentDir = await createTempWorkspaceDir();
const skillsDir = path.join(parentDir, "skills");
await writeSkill({
dir: path.join(skillsDir, "d0", "d1", "d2", "d3", "d4", "d5"),
name: "configured-deep-skill",
description: "Depth 6 from configured skills root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillsDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("configured-deep-skill");
});
it("uses the nested skills folder as the depth root for repo-style extra dirs", async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(repoDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"),
name: "repo-depth-skill",
description: "Depth 6 from nested skills root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("repo-depth-skill");
});
it("ignores invalid outside candidates when resolving repo-style extra dirs", async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
await fs.mkdir(path.join(repoDir, "examples", "bad"), { recursive: true });
await fs.writeFile(
path.join(repoDir, "examples", "bad", "SKILL.md"),
"---\nname: bad\n---\n",
);
await writeSkill({
dir: path.join(repoDir, "skills", "group", "valid"),
name: "repo-nested-skill",
description: "Valid nested repo skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("repo-nested-skill");
expect(names).not.toContain("bad");
});
it("ignores invalid root SKILL.md files when resolving repo-style extra dirs", async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
await fs.writeFile(path.join(repoDir, "SKILL.md"), "---\nname: bad\n---\n");
await writeSkill({
dir: path.join(repoDir, "examples", "valid"),
name: "outside-valid-skill",
description: "Valid outside repo skill",
});
await writeSkill({
dir: path.join(repoDir, "skills", "group", "valid"),
name: "repo-nested-skill",
description: "Valid nested repo skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("repo-nested-skill");
expect(names).not.toContain("outside-valid-skill");
expect(names).not.toContain("bad");
});
it("treats invalid outside SKILL.md files as terminal during repo-root detection", async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
await fs.mkdir(path.join(repoDir, "examples", "bad", "child"), { recursive: true });
await fs.writeFile(
path.join(repoDir, "examples", "bad", "SKILL.md"),
"---\nname: bad\n---\n",
);
await writeSkill({
dir: path.join(repoDir, "examples", "bad", "child"),
name: "outside-child",
description: "Valid child hidden behind invalid terminal parent",
});
await writeSkill({
dir: path.join(repoDir, "skills", "group", "valid"),
name: "repo-nested-skill",
description: "Valid nested repo skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("repo-nested-skill");
expect(names).not.toContain("outside-child");
});
it.runIf(process.platform !== "win32")(
"does not follow outside symlink dirs during repo-root detection",
async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
const outsideDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(outsideDir, "linked"),
name: "outside-linked-skill",
description: "Outside linked skill",
});
await fs.mkdir(path.join(repoDir, "examples"), { recursive: true });
await fs.symlink(outsideDir, path.join(repoDir, "examples", "linked"), "dir");
await writeSkill({
dir: path.join(repoDir, "skills", "group", "valid"),
name: "repo-nested-skill",
description: "Valid nested repo skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("repo-nested-skill");
expect(names).not.toContain("outside-linked-skill");
},
);
it.runIf(process.platform !== "win32")(
"keeps configured roots with possible symlink skills outside nested skills",
async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
const targetRoot = path.join(tempRoot, `linked-root-${workspaceCaseIndex++}`);
const targetSkillDir = path.join(targetRoot, "linked-skill");
await writeSkill({
dir: targetSkillDir,
name: "linked-skill",
description: "Allowed linked skill",
});
await fs.mkdir(path.join(repoDir, "group"), { recursive: true });
await fs.symlink(targetSkillDir, path.join(repoDir, "group", "linked-skill"), "dir");
await writeSkill({
dir: path.join(repoDir, "skills", "group", "valid"),
name: "repo-nested-skill",
description: "Valid nested repo skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: {
allowSymlinkTargets: [targetRoot],
extraDirs: [repoDir],
},
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("linked-skill");
expect(names).toContain("repo-nested-skill");
},
);
it("keeps a configured direct skill root even when it has nested skill fixtures", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillDir = await createTempWorkspaceDir();
await writeSkill({
dir: skillDir,
name: "direct-root",
description: "Configured direct skill root",
});
await writeSkill({
dir: path.join(skillDir, "skills", "examples", "fixture"),
name: "fixture-skill",
description: "Nested fixture skill should not replace the root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("direct-root");
expect(names).not.toContain("fixture-skill");
});
it("does not re-root extra dirs from ignored nested skill files", async () => {
const workspaceDir = await createTempWorkspaceDir();
const repoDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(repoDir, "valid"),
name: "valid-root-skill",
description: "Direct child skill under configured root",
});
await writeSkill({
dir: path.join(repoDir, "skills", "node_modules", "pkg"),
name: "ignored-package-skill",
description: "Ignored nested dependency fixture",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [repoDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("valid-root-skill");
expect(names).not.toContain("ignored-package-skill");
});
it("keeps direct child skills when a configured root also has a skills child", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillRootDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(skillRootDir, "valid"),
name: "valid-root-skill",
description: "Direct child skill under configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "skills", "examples", "fixture"),
name: "fixture-skill",
description: "Nested fixture should not replace the configured root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillRootDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("valid-root-skill");
expect(names).toContain("fixture-skill");
});
it("keeps nested skills when top-level candidate cap is filled by direct skills", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillRootDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(skillRootDir, "00-valid"),
name: "valid-root-skill",
description: "Direct child skill under configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "skills", "examples", "fixture"),
name: "fixture-skill",
description: "Nested fixture should still be scanned",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillRootDir] },
limits: {
maxCandidatesPerRoot: 1,
maxSkillsLoadedPerSource: 10,
},
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("valid-root-skill");
expect(names).toContain("fixture-skill");
});
it("keeps nested skills depth when a configured root also has direct skills", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillRootDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(skillRootDir, "valid"),
name: "valid-root-skill",
description: "Direct child skill under configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"),
name: "deep-nested-skill",
description: "Depth 6 from nested skills root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillRootDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("valid-root-skill");
expect(names).toContain("deep-nested-skill");
});
it("keeps configured root grouping outside skills within watcher depth", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillRootDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(skillRootDir, "group", "within-depth"),
name: "within-depth",
description: "Depth 2 from configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "group", "d1", "too-deep"),
name: "too-deep",
description: "Depth 3 from configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"),
name: "deep-nested-skill",
description: "Depth 6 from nested skills root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillRootDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("within-depth");
expect(names).toContain("deep-nested-skill");
expect(names).not.toContain("too-deep");
});
it("keeps grouped child skills when a configured root also has a skills child", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillRootDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(skillRootDir, "group", "valid"),
name: "valid-grouped-skill",
description: "Grouped child skill under configured root",
});
await writeSkill({
dir: path.join(skillRootDir, "skills", "examples", "fixture"),
name: "fixture-skill",
description: "Nested fixture should not replace the configured root",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
load: { extraDirs: [skillRootDir] },
},
},
}).map((entry) => entry.skill.name);
expect(names).toContain("valid-grouped-skill");
expect(names).toContain("fixture-skill");
});
it("does not descend beyond the bounded grouped skill depth", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "d0", "d1", "d2", "d3", "d4", "d5"),
name: "within-depth",
description: "Depth 6 loads",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "e0", "e1", "e2", "e3", "e4", "e5", "e6"),
name: "too-deep",
description: "Depth 7 does not load",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).toContain("within-depth");
expect(names).not.toContain("too-deep");
});
@@ -750,15 +1214,13 @@ describe("loadWorkspaceSkillEntries", () => {
expect(names).not.toContain("too-deep");
});
it("prefers the immediate SKILL.md and does not descend when one is present", async () => {
it("treats an immediate SKILL.md as terminal and does not descend", async () => {
const workspaceDir = await createTempWorkspaceDir();
// skills/group/SKILL.md exists -> treat group as the skill itself.
await writeSkill({
dir: path.join(workspaceDir, "skills", "group"),
name: "group",
description: "Direct skill at the group level",
});
// skills/group/inner/SKILL.md should NOT be loaded as a separate skill.
await writeSkill({
dir: path.join(workspaceDir, "skills", "group", "inner"),
name: "inner",

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -55,78 +56,478 @@ describe("ensureSkillsWatcher", () => {
await refreshModule.resetSkillsRefreshForTest();
});
it("watches skill roots and filters non-skill churn", () => {
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
it("watches skill roots and filters non-skill churn", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-root-"));
try {
refreshModule.ensureSkillsWatcher({ workspaceDir });
// Each unique directory gets its own watcher (one path argument per call).
const calls = watchMock.mock.calls as unknown as Array<
[string, { depth?: number; ignored?: unknown }]
>;
expect(calls.length).toBeGreaterThan(0);
const targets = calls.map((call) => call[0]);
const opts = calls[0]?.[1] ?? {};
// Each unique directory gets its own watcher (one path argument per call).
const calls = watchMock.mock.calls as unknown as Array<
[string, { depth?: number; followSymlinks?: boolean; ignored?: unknown }]
>;
expect(calls.length).toBeGreaterThan(0);
const targets = calls.map((call) => call[0]);
const opts = calls[0]?.[1] ?? {};
const workspaceSkillsRoot = path.join(workspaceDir, "skills").replaceAll("\\", "/");
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
expect(opts.depth).toBe(2);
const posix = (p: string) => p.replaceAll("\\", "/");
expect(targets).toContain(posix(path.join("/tmp/workspace", "skills")));
expect(targets).toContain(posix(path.join("/tmp/workspace", ".agents", "skills")));
expect(targets).toContain(posix(path.join(os.homedir(), ".agents", "skills")));
const wildcardTargets = targets.filter((target) => target.includes("*"));
expect(wildcardTargets).toStrictEqual([]);
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
expect(opts.followSymlinks).toBe(false);
const posix = (p: string) => p.replaceAll("\\", "/");
expect(targets).toContain(workspaceSkillsRoot);
expect(targets).toContain(posix(path.join(workspaceDir, ".agents", "skills")));
expect(calls.find(([p]) => p.replaceAll("\\", "/") === workspaceSkillsRoot)?.[1].depth).toBe(
7,
);
expect(targets).toContain(posix(path.join(os.homedir(), ".agents", "skills")));
const wildcardTargets = targets.filter((target) => target.includes("*"));
expect(wildcardTargets).toStrictEqual([]);
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
// Node/JS paths
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
// Node/JS paths
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
// Python virtual environments and caches
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
// Python virtual environments and caches
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
// Build artifacts and caches
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
// Build artifacts and caches
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
// Should NOT ignore normal skill files
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
// Should NOT ignore normal skill files
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isSymbolicLink: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("keeps grouped skill folders within the watcher traversal depth", async () => {
vi.useFakeTimers();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-depth-"));
const seen: SkillsChangeEvent[] = [];
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { watchDebounceMs: 10 } } },
});
try {
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { watchDebounceMs: 10 } } },
});
const firstCall = (
watchMock.mock.calls as unknown as Array<[string, { depth?: number; ignored?: unknown }]>
)[0];
expect(firstCall?.[1]?.depth).toBe(2);
const calls = watchMock.mock.calls as unknown as Array<
[string, { depth?: number; ignored?: unknown }]
>;
const workspaceSkillsRoot = path.join(workspaceDir, "skills").replaceAll("\\", "/");
const firstIndex = calls.findIndex(([p]) => p.replaceAll("\\", "/") === workspaceSkillsRoot);
expect(calls[firstIndex]?.[1]?.depth).toBe(7);
createdWatchers[0]?.emit("change", "/tmp/workspace/skills/group/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
const changedPath = path.join(workspaceDir, "skills", "group", "demo", "SKILL.md");
createdWatchers[firstIndex]?.emit("change", changedPath);
await vi.advanceTimersByTimeAsync(10);
expect(seen).toEqual([
{
workspaceDir: "/tmp/workspace",
reason: "watch",
changedPath: "/tmp/workspace/skills/group/demo/SKILL.md",
},
]);
expect(seen).toEqual([
{
workspaceDir,
reason: "watch",
changedPath,
},
]);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it.runIf(process.platform !== "win32")(
"watches allowed symlink skill targets without following every root symlink",
async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-symlink-"));
const targetRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-symlink-target-"));
try {
const workspaceSkillsDir = path.join(workspaceDir, "skills");
const targetSkillDir = path.join(targetRoot, "linked-skill");
const groupedLinkDir = path.join(workspaceSkillsDir, "group");
await fs.mkdir(groupedLinkDir, { recursive: true });
await fs.mkdir(targetSkillDir, { recursive: true });
await fs.writeFile(
path.join(targetSkillDir, "SKILL.md"),
"---\nname: linked-skill\ndescription: Linked\n---\n",
);
await fs.symlink(targetSkillDir, path.join(groupedLinkDir, "linked-skill"), "dir");
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { allowSymlinkTargets: [targetRoot] } } },
});
const calls = watchMock.mock.calls as unknown as Array<
[string, { followSymlinks?: boolean }]
>;
const target = (await fs.realpath(targetSkillDir)).replaceAll("\\", "/");
expect(calls.find(([p]) => p.replaceAll("\\", "/") === target)?.[1].followSymlinks).toBe(
false,
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(targetRoot, { recursive: true, force: true });
}
},
);
it.runIf(process.platform !== "win32")("watches symlinked skill root targets", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-root-link-"));
const targetSkillsDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-watch-root-link-target-"),
);
try {
await fs.writeFile(
path.join(targetSkillsDir, "SKILL.md"),
"---\nname: linked-root\ndescription: Linked root\n---\n",
);
await fs.symlink(targetSkillsDir, path.join(workspaceDir, "skills"), "dir");
refreshModule.ensureSkillsWatcher({ workspaceDir });
const calls = watchMock.mock.calls as unknown as Array<
[string, { followSymlinks?: boolean }]
>;
const target = (await fs.realpath(targetSkillsDir)).replaceAll("\\", "/");
expect(calls.find(([p]) => p.replaceAll("\\", "/") === target)?.[1].followSymlinks).toBe(
false,
);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(targetSkillsDir, { recursive: true, force: true });
}
});
it.runIf(process.platform !== "win32")(
"does not watch untrusted companion skills symlink targets",
async () => {
const workspaceDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-watch-untrusted-link-"),
);
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-untrusted-repo-"));
const outsideDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-watch-untrusted-target-"),
);
try {
await fs.writeFile(
path.join(outsideDir, "SKILL.md"),
"---\nname: untrusted\ndescription: Untrusted\n---\n",
);
await fs.symlink(outsideDir, path.join(repoDir, "skills"), "dir");
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { extraDirs: [repoDir] } } },
});
const target = (await fs.realpath(outsideDir)).replaceAll("\\", "/");
const targets = (watchMock.mock.calls as unknown as Array<[string]>).map(([p]) =>
p.replaceAll("\\", "/"),
);
expect(targets).not.toContain(target);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(repoDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
},
);
it("watches nested skills roots for repo-style extra dirs", async () => {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-watch-"));
try {
await fs.mkdir(path.join(repoDir, "skills", "group", "demo"), { recursive: true });
await fs.writeFile(
path.join(repoDir, "skills", "group", "demo", "SKILL.md"),
"---\nname: demo\ndescription: Demo\n---\n",
);
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { extraDirs: [repoDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const repoRoot = repoDir.replaceAll("\\", "/");
const nestedRoot = path.join(repoDir, "skills").replaceAll("\\", "/");
expect(targets).toContain(nestedRoot);
expect(targets).toContain(repoRoot);
expect(targets).not.toContain(path.join(repoDir, "SKILL.md").replaceAll("\\", "/"));
expect(calls.find(([p]) => p.replaceAll("\\", "/") === repoRoot)?.[1].depth).toBe(2);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(6);
} finally {
await fs.rm(repoDir, { recursive: true, force: true });
}
});
it("watches nested skills roots for built-in workspace skill dirs", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-skills-"));
try {
await fs.mkdir(path.join(workspaceDir, "skills", "skills", "group", "demo"), {
recursive: true,
});
await fs.writeFile(
path.join(workspaceDir, "skills", "skills", "group", "demo", "SKILL.md"),
"---\nname: demo\ndescription: Demo\n---\n",
);
refreshModule.ensureSkillsWatcher({ workspaceDir });
const targets = (watchMock.mock.calls as unknown as Array<[string, object]>).map(([p]) =>
p.replaceAll("\\", "/"),
);
expect(targets).toContain(path.join(workspaceDir, "skills").replaceAll("\\", "/"));
expect(targets).toContain(path.join(workspaceDir, "skills", "skills").replaceAll("\\", "/"));
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("reuses watch roots while config is unchanged", async () => {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-watch-cache-"));
try {
await fs.mkdir(path.join(repoDir, "skills", "group", "demo"), { recursive: true });
await fs.writeFile(
path.join(repoDir, "skills", "group", "demo", "SKILL.md"),
"---\nname: demo\ndescription: Demo\n---\n",
);
const config = { skills: { load: { extraDirs: [repoDir] } } };
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace", config });
const firstCallCount = watchMock.mock.calls.length;
await fs.rm(path.join(repoDir, "skills"), { recursive: true, force: true });
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace", config });
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const repoRoot = repoDir.replaceAll("\\", "/");
const nestedRoot = path.join(repoDir, "skills").replaceAll("\\", "/");
expect(watchMock).toHaveBeenCalledTimes(firstCallCount);
expect(targets).toContain(nestedRoot);
expect(targets).toContain(repoRoot);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === repoRoot)?.[1].depth).toBe(2);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(6);
} finally {
await fs.rm(repoDir, { recursive: true, force: true });
}
});
it("watches extra-dir roots and companion skills folders without resolving them", async () => {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-watch-pair-"));
try {
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { extraDirs: [repoDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const repoRoot = repoDir.replaceAll("\\", "/");
const nestedRoot = path.join(repoDir, "skills").replaceAll("\\", "/");
expect(targets).toContain(nestedRoot);
expect(targets).toContain(repoRoot);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === repoRoot)?.[1].depth).toBe(2);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(7);
} finally {
await fs.rm(repoDir, { recursive: true, force: true });
}
});
it("bumps missing configured root depth for first nested skill creation", async () => {
const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-missing-skill-root-"));
try {
const missingRoot = path.join(parentDir, "repo");
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { extraDirs: [missingRoot] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const root = missingRoot.replaceAll("\\", "/");
const nestedRoot = path.join(missingRoot, "skills").replaceAll("\\", "/");
expect(calls.find(([p]) => p.replaceAll("\\", "/") === root)?.[1].depth).toBe(3);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(8);
} finally {
await fs.rm(parentDir, { recursive: true, force: true });
}
});
it("watches configured roots named skills at grouped depth", async () => {
const parentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-configured-skills-root-"));
try {
const skillsDir = path.join(parentDir, "skills");
await fs.mkdir(skillsDir, { recursive: true });
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { extraDirs: [skillsDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const root = skillsDir.replaceAll("\\", "/");
expect(calls.find(([p]) => p.replaceAll("\\", "/") === root)?.[1].depth).toBe(6);
} finally {
await fs.rm(parentDir, { recursive: true, force: true });
}
});
it("dedupes overlapping watch roots by path while keeping the deepest depth", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-dedupe-"));
try {
const skillsDir = path.join(workspaceDir, "skills");
await fs.mkdir(skillsDir, { recursive: true });
refreshModule.ensureSkillsWatcher({
workspaceDir,
config: { skills: { load: { extraDirs: [skillsDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const root = skillsDir.replaceAll("\\", "/");
const overlapping = calls.filter(([p]) => p.replaceAll("\\", "/") === root);
expect(overlapping).toHaveLength(1);
expect(overlapping[0]?.[1].depth).toBe(6);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("does not downgrade a shared watcher when a shallow subscriber arrives later", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-share-a-"));
const otherDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-watch-share-b-"));
try {
const skillsDir = path.join(workspaceDir, "skills");
await fs.mkdir(skillsDir, { recursive: true });
refreshModule.ensureSkillsWatcher({ workspaceDir });
const firstCalls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const root = skillsDir.replaceAll("\\", "/");
const firstIndex = firstCalls.findIndex(([p]) => p.replaceAll("\\", "/") === root);
refreshModule.ensureSkillsWatcher({
workspaceDir: otherDir,
config: { skills: { load: { extraDirs: [skillsDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const overlapping = calls.filter(([p]) => p.replaceAll("\\", "/") === root);
expect(overlapping).toHaveLength(1);
expect(overlapping[0]?.[1].depth).toBe(6);
expect(createdWatchers[firstIndex]?.close).not.toHaveBeenCalled();
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
await fs.rm(otherDir, { recursive: true, force: true });
}
});
it("watches extra-dir skills folders for first nested skill creation", async () => {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-watch-create-"));
try {
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { extraDirs: [repoDir] } } },
});
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const nestedRoot = path.join(repoDir, "skills").replaceAll("\\", "/");
expect(targets).toContain(repoDir.replaceAll("\\", "/"));
expect(targets).toContain(nestedRoot);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(7);
} finally {
await fs.rm(repoDir, { recursive: true, force: true });
}
});
it("watches nested skills roots for plugin skill dirs", async () => {
const pluginDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-skills-watch-"));
try {
await fs.mkdir(path.join(pluginDir, "skills", "group", "demo"), { recursive: true });
await fs.writeFile(
path.join(pluginDir, "skills", "group", "demo", "SKILL.md"),
"---\nname: demo\ndescription: Demo\n---\n",
);
const pluginSkills = await import("./plugin-skills.js");
vi.mocked(pluginSkills.resolvePluginSkillDirs).mockReturnValueOnce([pluginDir]);
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const pluginRoot = pluginDir.replaceAll("\\", "/");
const nestedRoot = path.join(pluginDir, "skills").replaceAll("\\", "/");
expect(targets).toContain(nestedRoot);
expect(targets).toContain(pluginRoot);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === pluginRoot)?.[1].depth).toBe(2);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(6);
} finally {
await fs.rm(pluginDir, { recursive: true, force: true });
}
});
it("watches plugin skills folders for first nested skill creation", async () => {
const pluginDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-plugin-skills-watch-create-"),
);
try {
const pluginSkills = await import("./plugin-skills.js");
vi.mocked(pluginSkills.resolvePluginSkillDirs).mockReturnValueOnce([pluginDir]);
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
const calls = watchMock.mock.calls as unknown as Array<[string, { depth?: number }]>;
const targets = calls.map(([p]) => p.replaceAll("\\", "/"));
const nestedRoot = path.join(pluginDir, "skills").replaceAll("\\", "/");
expect(targets).toContain(pluginDir.replaceAll("\\", "/"));
expect(targets).toContain(nestedRoot);
expect(calls.find(([p]) => p.replaceAll("\\", "/") === nestedRoot)?.[1].depth).toBe(7);
} finally {
await fs.rm(pluginDir, { recursive: true, force: true });
}
});
it.runIf(process.platform !== "win32")(
"does not watch untrusted plugin skill symlink targets",
async () => {
const pluginDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-plugin-skills-untrusted-link-"),
);
const outsideDir = await fs.mkdtemp(
path.join(os.tmpdir(), "openclaw-plugin-skills-untrusted-target-"),
);
try {
await fs.mkdir(path.join(pluginDir, "skills"), { recursive: true });
await fs.writeFile(
path.join(outsideDir, "SKILL.md"),
"---\nname: untrusted-plugin\ndescription: Untrusted plugin\n---\n",
);
await fs.symlink(outsideDir, path.join(pluginDir, "skills", "untrusted"), "dir");
const pluginSkills = await import("./plugin-skills.js");
vi.mocked(pluginSkills.resolvePluginSkillDirs).mockReturnValueOnce([pluginDir]);
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
const target = (await fs.realpath(outsideDir)).replaceAll("\\", "/");
const targets = (watchMock.mock.calls as unknown as Array<[string]>).map(([p]) =>
p.replaceAll("\\", "/"),
);
expect(targets).not.toContain(target);
} finally {
await fs.rm(pluginDir, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
}
},
);
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"refreshes skills snapshots on %s",
async (event) => {
@@ -195,10 +596,10 @@ describe("ensureSkillsWatcher", () => {
});
const callPaths = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
// The shared directory is watched exactly once even though two workspaces
// Each shared target is watched exactly once even though two workspaces
// include it, instead of one watcher per workspace (the EMFILE root cause).
const sharedWatchers = callPaths.filter((target) => target.includes("/tmp/shared"));
expect(sharedWatchers).toHaveLength(1);
expect(callPaths.filter((target) => target === "/tmp/shared")).toHaveLength(1);
expect(callPaths.filter((target) => target === "/tmp/shared/skills")).toHaveLength(1);
});
it("fans out a shared-directory change to every subscribed workspace", async () => {
@@ -283,7 +684,7 @@ describe("ensureSkillsWatcher", () => {
config: { skills: { load: { extraDirs: ["/tmp/shared"], watchDebounceMs: 10 } } },
});
const callPaths1 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const firstSharedIndex = callPaths1.findIndex((target) => target.includes("/tmp/shared"));
const firstSharedIndex = callPaths1.findIndex((target) => target === "/tmp/shared");
// ws-b subscribes to the same path with a different debounce: the shared
// watcher is rebuilt once, the previous instance closed, and both
@@ -296,9 +697,10 @@ describe("ensureSkillsWatcher", () => {
expect(createdWatchers[firstSharedIndex]?.close).toHaveBeenCalledTimes(1);
const callPaths2 = (watchMock.mock.calls as unknown as Array<[string]>).map((call) => call[0]);
const sharedIndices = callPaths2
.map((target, index) => (target.includes("/tmp/shared") ? index : -1))
.map((target, index) => (target === "/tmp/shared" ? index : -1))
.filter((index) => index >= 0);
expect(sharedIndices).toHaveLength(2);
expect(callPaths2.filter((target) => target === "/tmp/shared/skills")).toHaveLength(2);
const liveSharedIndex = sharedIndices[sharedIndices.length - 1] ?? -1;
createdWatchers[liveSharedIndex]?.emit("change", "/tmp/shared/demo/SKILL.md");

View File

@@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import chokidar, { type FSWatcher } from "chokidar";
@@ -21,13 +22,30 @@ export {
type SkillsPathWatchState = {
watcher: FSWatcher;
depth: number;
debounceMs: number;
timer?: ReturnType<typeof setTimeout>;
pendingPath?: string;
readonly subscribers: Set<string>;
};
type WatchTarget = {
path: string;
depth: number;
key: string;
};
type WatchTargetCacheEntry = {
signature: string;
targets: WatchTarget[];
};
const log = createSubsystemLogger("gateway/skills");
const GROUPED_SKILLS_WATCH_DEPTH = 6;
const CONFIGURED_ROOT_WATCH_DEPTH = 2;
const MAX_SYMLINK_WATCH_TARGETS_PER_ROOT = 100;
const MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT = 200;
const MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT = 2_000;
// One watcher per unique watched directory. Agent workspaces that include the
// same shared skill root (the global skills dir, the home skills dir, or a
// configured extra/plugin dir) subscribe to the same watcher instead of each
@@ -36,7 +54,11 @@ const log = createSubsystemLogger("gateway/skills");
const pathWatchers = new Map<string, SkillsPathWatchState>();
// Watch targets each workspace is currently subscribed to, used to reconcile
// subscriptions and to detect watch-target changes across calls.
const workspaceWatchTargets = new Map<string, string[]>();
const workspaceWatchTargets = new Map<string, WatchTarget[]>();
// Resolved nested skill watch roots are filesystem-derived. Cache them so the
// per-turn watcher reconciliation path stays cheap until config or watched
// filesystem changes require a fresh root scan.
const workspaceWatchTargetCache = new Map<string, WatchTargetCacheEntry>();
setSkillsChangeListenerErrorHandler((err) => {
log.warn(`skills change listener failed: ${String(err)}`);
@@ -57,23 +79,105 @@ export const DEFAULT_SKILLS_WATCH_IGNORED: RegExp[] = [
/(^|[\\/])\.cache([\\/]|$)/,
];
function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): string[] {
const paths: string[] = [];
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): WatchTarget[] {
const baseRoots: Array<{ path: string; source: string }> = [];
if (workspaceDir.trim()) {
paths.push(path.join(workspaceDir, "skills"));
paths.push(path.join(workspaceDir, ".agents", "skills"));
baseRoots.push({ path: path.join(workspaceDir, "skills"), source: "openclaw-workspace" });
baseRoots.push({
path: path.join(workspaceDir, ".agents", "skills"),
source: "agents-skills-project",
});
}
paths.push(path.join(CONFIG_DIR, "skills"));
paths.push(path.join(os.homedir(), ".agents", "skills"));
baseRoots.push({ path: path.join(CONFIG_DIR, "skills"), source: "openclaw-managed" });
baseRoots.push({
path: path.join(os.homedir(), ".agents", "skills"),
source: "agents-skills-personal",
});
const extraDirsRaw = config?.skills?.load?.extraDirs ?? [];
const extraDirs = extraDirsRaw
.map((d) => normalizeOptionalString(d) ?? "")
.filter(Boolean)
.map((dir) => resolveUserPath(dir));
paths.push(...extraDirs);
const pluginSkillDirs = resolvePluginSkillDirs({ workspaceDir, config });
paths.push(...pluginSkillDirs);
return paths;
const allowedSymlinkTargetRealPaths = resolveAllowedSymlinkTargetRealPaths(config);
const signature = JSON.stringify({
basePaths: baseRoots.map((root) => toWatchRoot(root.path)),
extraDirs: extraDirs.map(toWatchRoot),
pluginSkillDirs: pluginSkillDirs.map(toWatchRoot),
allowSymlinkTargets: allowedSymlinkTargetRealPaths,
});
const cached = workspaceWatchTargetCache.get(workspaceDir);
if (cached?.signature === signature) {
return cached.targets;
}
const targets = new Map<string, WatchTarget>();
for (const root of baseRoots) {
addSkillRootWatchTargets(targets, root.path, GROUPED_SKILLS_WATCH_DEPTH);
addTrustedSymlinkSkillWatchTargets(
targets,
root.path,
root.source,
allowedSymlinkTargetRealPaths,
GROUPED_SKILLS_WATCH_DEPTH,
root.path,
);
addTrustedSymlinkSkillWatchTargets(
targets,
path.join(root.path, "skills"),
root.source,
allowedSymlinkTargetRealPaths,
GROUPED_SKILLS_WATCH_DEPTH,
root.path,
);
}
for (const resolved of extraDirs) {
const rootDepth =
path.basename(resolved) === "skills"
? GROUPED_SKILLS_WATCH_DEPTH
: CONFIGURED_ROOT_WATCH_DEPTH;
addSkillRootWatchTargets(targets, resolved, rootDepth);
addTrustedSymlinkSkillWatchTargets(
targets,
resolved,
"openclaw-extra",
allowedSymlinkTargetRealPaths,
rootDepth,
resolved,
);
addTrustedSymlinkSkillWatchTargets(
targets,
path.join(resolved, "skills"),
"openclaw-extra",
allowedSymlinkTargetRealPaths,
GROUPED_SKILLS_WATCH_DEPTH,
resolved,
);
}
for (const dir of pluginSkillDirs) {
const rootDepth =
path.basename(dir) === "skills" ? GROUPED_SKILLS_WATCH_DEPTH : CONFIGURED_ROOT_WATCH_DEPTH;
addSkillRootWatchTargets(targets, dir, rootDepth);
addTrustedSymlinkSkillWatchTargets(
targets,
dir,
"openclaw-plugin",
allowedSymlinkTargetRealPaths,
rootDepth,
dir,
);
addTrustedSymlinkSkillWatchTargets(
targets,
path.join(dir, "skills"),
"openclaw-plugin",
allowedSymlinkTargetRealPaths,
GROUPED_SKILLS_WATCH_DEPTH,
dir,
);
}
const sortedTargets = Array.from(targets.values()).toSorted((a, b) => a.key.localeCompare(b.key));
workspaceWatchTargetCache.set(workspaceDir, { signature, targets: sortedTargets });
return sortedTargets;
}
function toWatchRoot(raw: string): string {
@@ -81,22 +185,210 @@ function toWatchRoot(raw: string): string {
return normalized.replace(/\/+$/, "") || normalized;
}
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] {
const targets = new Set<string>();
for (const root of resolveWatchPaths(workspaceDir, config)) {
targets.add(toWatchRoot(root));
function makeWatchTarget(raw: string, depth: number): WatchTarget {
const watchPath = toWatchRoot(raw);
return { path: watchPath, depth, key: watchPath };
}
function addWatchTarget(targets: Map<string, WatchTarget>, raw: string, depth: number): void {
const target = makeWatchTarget(raw, depth);
const existing = targets.get(target.key);
if (existing) {
existing.depth = Math.max(existing.depth, target.depth);
return;
}
return Array.from(targets).toSorted();
targets.set(target.key, target);
}
function addSkillRootWatchTargets(
targets: Map<string, WatchTarget>,
root: string,
rootDepth: number,
): void {
addWatchTarget(targets, root, watchDepthForPath(root, rootDepth));
const companionSkillsRoot = path.join(root, "skills");
addWatchTarget(
targets,
companionSkillsRoot,
watchDepthForPath(companionSkillsRoot, GROUPED_SKILLS_WATCH_DEPTH),
);
}
function addTrustedSymlinkSkillWatchTargets(
targets: Map<string, WatchTarget>,
root: string,
source: string,
allowedSymlinkTargetRealPaths: readonly string[],
maxDepth: number,
containmentRoot: string,
): void {
const containmentRootRealPath = tryRealpath(containmentRoot) ?? path.resolve(containmentRoot);
const rootRealPath = tryRealpath(root) ?? path.resolve(root);
try {
if (
fs.lstatSync(root).isSymbolicLink() &&
isTrustedSymlinkSkillTarget(
source,
containmentRootRealPath,
rootRealPath,
allowedSymlinkTargetRealPaths,
)
) {
addSkillRootWatchTargets(targets, rootRealPath, maxDepth);
}
} catch {
return;
}
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
let watched = 0;
let directoryScans = 0;
let rawEntries = 0;
for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
if (
watched >= MAX_SYMLINK_WATCH_TARGETS_PER_ROOT ||
directoryScans >= MAX_SYMLINK_WATCH_DIRECTORY_SCANS_PER_ROOT ||
rawEntries >= MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT
) {
break;
}
const current = queue[queueIndex];
if (!current) {
continue;
}
const scan = readBudgetedDirEntries(
current.dir,
MAX_SYMLINK_WATCH_RAW_ENTRIES_PER_ROOT - rawEntries,
);
directoryScans += 1;
rawEntries += scan.scannedEntryCount;
if (!scan.ok) {
continue;
}
for (const entry of scan.entries.toSorted((a, b) => a.name.localeCompare(b.name))) {
if (watched >= MAX_SYMLINK_WATCH_TARGETS_PER_ROOT) {
break;
}
if (entry.name.startsWith(".") || entry.name === "node_modules") {
continue;
}
const childPath = path.join(current.dir, entry.name);
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(childPath))) {
continue;
}
if (entry.isSymbolicLink()) {
const targetRealPath = tryRealpath(childPath);
if (
targetRealPath &&
isTrustedSymlinkSkillTarget(
source,
containmentRootRealPath,
targetRealPath,
allowedSymlinkTargetRealPaths,
)
) {
addSkillRootWatchTargets(targets, targetRealPath, GROUPED_SKILLS_WATCH_DEPTH);
watched += 1;
}
continue;
}
if (entry.isDirectory() && current.depth < maxDepth) {
queue.push({ dir: childPath, depth: current.depth + 1 });
}
}
}
}
function readBudgetedDirEntries(
dir: string,
maxEntries: number,
):
| { ok: true; entries: fs.Dirent[]; scannedEntryCount: number }
| { ok: false; scannedEntryCount: number } {
const entries: fs.Dirent[] = [];
const limit = Math.max(0, maxEntries);
let handle: fs.Dir | undefined;
try {
handle = fs.opendirSync(dir);
for (let scanned = 0; scanned < limit; scanned += 1) {
const entry = handle.readSync();
if (!entry) {
return { ok: true, entries, scannedEntryCount: scanned };
}
entries.push(entry);
}
return { ok: true, entries, scannedEntryCount: limit };
} catch {
return { ok: false, scannedEntryCount: 0 };
} finally {
handle?.closeSync();
}
}
function isTrustedSymlinkSkillTarget(
source: string,
rootRealPath: string,
targetRealPath: string,
allowedSymlinkTargetRealPaths: readonly string[],
): boolean {
if (source === "openclaw-managed" || source === "agents-skills-personal") {
return true;
}
return (
isPathInside(rootRealPath, targetRealPath) ||
isPathInsideAnyRoot(allowedSymlinkTargetRealPaths, targetRealPath)
);
}
function watchDepthForPath(raw: string, depth: number): number {
let missingSegments = 0;
let candidate = raw;
while (!fs.existsSync(candidate)) {
const parent = path.dirname(candidate);
if (parent === candidate) {
break;
}
missingSegments += 1;
candidate = parent;
}
return depth + missingSegments;
}
function resolveAllowedSymlinkTargetRealPaths(config?: OpenClawConfig): string[] {
const rawTargets = config?.skills?.load?.allowSymlinkTargets ?? [];
return rawTargets
.map((dir) => normalizeOptionalString(dir) ?? "")
.filter(Boolean)
.map((dir) => tryRealpath(resolveUserPath(dir)))
.filter((dir): dir is string => Boolean(dir));
}
function tryRealpath(filePath: string): string | null {
try {
return fs.realpathSync(filePath);
} catch {
return null;
}
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return (
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function isPathInsideAnyRoot(roots: readonly string[], child: string): boolean {
return roots.some((root) => isPathInside(root, child));
}
export function shouldIgnoreSkillsWatchPath(
watchPath: string,
stats?: { isDirectory?: () => boolean },
stats?: { isDirectory?: () => boolean; isSymbolicLink?: () => boolean },
): boolean {
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) {
return true;
}
if (stats?.isDirectory?.()) {
if (stats?.isDirectory?.() || stats?.isSymbolicLink?.()) {
return false;
}
if (!stats) {
@@ -113,23 +405,25 @@ function resolveWatchDebounceMs(config?: OpenClawConfig): number {
// Requires resolveWatchTargets to produce a stable-order result (it returns a
// sorted array); positional comparison is intentional for hot-path efficiency.
function sameWatchTargets(a: string[], b: string[]): boolean {
function sameWatchTargets(a: WatchTarget[], b: WatchTarget[]): boolean {
if (a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index++) {
if (a[index] !== b[index]) {
if (a[index]?.key !== b[index]?.key || a[index]?.depth !== b[index]?.depth) {
return false;
}
}
return true;
}
function createSkillsPathWatcher(watchPath: string, debounceMs: number): SkillsPathWatchState {
const watcher = chokidar.watch(watchPath, {
function createSkillsPathWatcher(target: WatchTarget, debounceMs: number): SkillsPathWatchState {
const watcher = chokidar.watch(target.path, {
ignoreInitial: true,
// Skill discovery reads root skills, direct child skills, and one grouped skill level.
depth: 2,
followSymlinks: false,
// Skill root precedence and grouped discovery use the same bounded depth,
// so watcher invalidation must observe that whole decision surface.
depth: target.depth,
awaitWriteFinish: {
stabilityThreshold: debounceMs,
pollInterval: 100,
@@ -137,7 +431,12 @@ function createSkillsPathWatcher(watchPath: string, debounceMs: number): SkillsP
ignored: shouldIgnoreSkillsWatchPath,
});
const state: SkillsPathWatchState = { watcher, debounceMs, subscribers: new Set<string>() };
const state: SkillsPathWatchState = {
watcher,
depth: target.depth,
debounceMs,
subscribers: new Set<string>(),
};
const schedule = (changedPath?: string) => {
state.pendingPath = changedPath ?? state.pendingPath;
@@ -151,6 +450,7 @@ function createSkillsPathWatcher(watchPath: string, debounceMs: number): SkillsP
// Fan the change out to every workspace subscribed to this directory so a
// shared skill root refreshes the snapshot for all agents that use it.
for (const workspaceDir of state.subscribers) {
workspaceWatchTargetCache.delete(workspaceDir);
bumpSkillsSnapshotVersion({
workspaceDir,
reason: "watch",
@@ -165,7 +465,7 @@ function createSkillsPathWatcher(watchPath: string, debounceMs: number): SkillsP
watcher.on("unlink", (p) => schedule(p));
watcher.on("unlinkDir", (p) => schedule(p));
watcher.on("error", (err) => {
log.warn(`skills watcher error (${watchPath}): ${String(err)}`);
log.warn(`skills watcher error (${target.path}): ${String(err)}`);
});
return state;
@@ -180,11 +480,11 @@ function teardownSkillsPathWatcher(state: SkillsPathWatchState): void {
function subscribeWorkspaceToPath(
workspaceDir: string,
watchPath: string,
watchTarget: WatchTarget,
debounceMs: number,
): void {
const existing = pathWatchers.get(watchPath);
if (existing && existing.debounceMs === debounceMs) {
const existing = pathWatchers.get(watchTarget.key);
if (existing && existing.debounceMs === debounceMs && existing.depth >= watchTarget.depth) {
existing.subscribers.add(workspaceDir);
return;
}
@@ -194,29 +494,32 @@ function subscribeWorkspaceToPath(
// value, so all workspaces normally request the same value and this branch
// does not fire; if it does, the most recent requested debounce wins for
// every subscriber of the shared path (last-writer-wins).
const next = createSkillsPathWatcher(watchPath, debounceMs);
const next = createSkillsPathWatcher(
{ ...watchTarget, depth: Math.max(existing.depth, watchTarget.depth) },
debounceMs,
);
for (const subscriber of existing.subscribers) {
next.subscribers.add(subscriber);
}
next.subscribers.add(workspaceDir);
teardownSkillsPathWatcher(existing);
pathWatchers.set(watchPath, next);
pathWatchers.set(watchTarget.key, next);
return;
}
const state = createSkillsPathWatcher(watchPath, debounceMs);
const state = createSkillsPathWatcher(watchTarget, debounceMs);
state.subscribers.add(workspaceDir);
pathWatchers.set(watchPath, state);
pathWatchers.set(watchTarget.key, state);
}
function unsubscribeWorkspaceFromPath(workspaceDir: string, watchPath: string): void {
const state = pathWatchers.get(watchPath);
function unsubscribeWorkspaceFromPath(workspaceDir: string, watchTarget: WatchTarget): void {
const state = pathWatchers.get(watchTarget.key);
if (!state) {
return;
}
state.subscribers.delete(workspaceDir);
if (state.subscribers.size === 0) {
teardownSkillsPathWatcher(state);
pathWatchers.delete(watchPath);
pathWatchers.delete(watchTarget.key);
}
}
@@ -231,10 +534,11 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
if (!watchEnabled) {
if (previousTargets.length > 0) {
for (const watchPath of previousTargets) {
unsubscribeWorkspaceFromPath(workspaceDir, watchPath);
for (const watchTarget of previousTargets) {
unsubscribeWorkspaceFromPath(workspaceDir, watchTarget);
}
workspaceWatchTargets.delete(workspaceDir);
workspaceWatchTargetCache.delete(workspaceDir);
}
return;
}
@@ -243,21 +547,24 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
const targetsUnchanged = sameWatchTargets(previousTargets, watchTargets);
const debounceUnchanged = watchTargets.every(
// undefined for paths not yet watched -> false -> fall through to subscribe.
(watchPath) => pathWatchers.get(watchPath)?.debounceMs === debounceMs,
(watchTarget) => {
const pathWatcher = pathWatchers.get(watchTarget.key);
return pathWatcher?.debounceMs === debounceMs && pathWatcher.depth >= watchTarget.depth;
},
);
if (targetsUnchanged && debounceUnchanged) {
return;
}
const watchTargetsChanged = previousTargets.length > 0 && !targetsUnchanged;
const nextTargets = new Set(watchTargets);
for (const watchPath of previousTargets) {
if (!nextTargets.has(watchPath)) {
unsubscribeWorkspaceFromPath(workspaceDir, watchPath);
const nextTargetKeys = new Set(watchTargets.map((target) => target.key));
for (const watchTarget of previousTargets) {
if (!nextTargetKeys.has(watchTarget.key)) {
unsubscribeWorkspaceFromPath(workspaceDir, watchTarget);
}
}
for (const watchPath of watchTargets) {
subscribeWorkspaceToPath(workspaceDir, watchPath, debounceMs);
for (const watchTarget of watchTargets) {
subscribeWorkspaceToPath(workspaceDir, watchTarget, debounceMs);
}
workspaceWatchTargets.set(workspaceDir, watchTargets);
@@ -265,7 +572,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
bumpSkillsSnapshotVersion({
workspaceDir,
reason: "watch-targets",
changedPath: watchTargets.join("|"),
changedPath: watchTargets.map((target) => target.path).join("|"),
});
}
}
@@ -276,6 +583,7 @@ export async function resetSkillsRefreshForTest(): Promise<void> {
const active = Array.from(pathWatchers.values());
pathWatchers.clear();
workspaceWatchTargets.clear();
workspaceWatchTargetCache.clear();
await Promise.all(
active.map(async (state) => {
if (state.timer) {

View File

@@ -144,6 +144,10 @@ const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 18_000;
const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000;
const DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN = 1_000;
const DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN = 10_000;
// Match Codex's bounded recursive skills discovery without letting broad
// workspace roots turn into unbounded filesystem walks.
const MAX_GROUPED_SKILL_SCAN_DEPTH = 6;
const MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH = 2;
type ResolvedSkillsLimits = {
maxCandidatesPerRoot: number;
@@ -173,6 +177,12 @@ type ChildDirectoryScan = {
truncated: boolean;
};
type SkillDiscoveryBudget = {
remainingDirectoryScans: number;
remainingRawEntries: number;
truncated: boolean;
};
function resolveSkillsLimits(config?: OpenClawConfig, agentId?: string): ResolvedSkillsLimits {
const limits = config?.skills?.limits;
const agentSkillsLimits = resolveEffectiveAgentSkillsLimits(config, agentId);
@@ -189,9 +199,14 @@ function resolveSkillsLimits(config?: OpenClawConfig, agentId?: string): Resolve
};
}
export function resolveSkillRootScanLimit(config?: OpenClawConfig): number {
return config?.skills?.limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT;
}
function listChildDirectories(
dir: string,
opts?: {
followSymlinks?: boolean;
maxCandidateDirs?: number;
maxRawEntriesToScan?: number;
},
@@ -203,7 +218,7 @@ function listChildDirectories(
const scan = walkDirectorySync(dir, {
maxDepth: 1,
maxEntries: maxRawEntriesToScan,
symlinks: "follow",
symlinks: opts?.followSymlinks === false ? "skip" : "follow",
include: (entry) =>
entry.kind === "directory" && !entry.name.startsWith(".") && entry.name !== "node_modules",
});
@@ -231,6 +246,134 @@ function resolveRawEntryScanLimit(maxCandidateDirs: number | undefined): number
);
}
function createSkillDiscoveryBudget(maxCandidateDirs: number): SkillDiscoveryBudget {
const normalized = Math.max(0, maxCandidateDirs);
return {
remainingDirectoryScans: normalized * MAX_GROUPED_SKILL_SCAN_DEPTH,
remainingRawEntries: resolveRawEntryScanLimit(normalized) * (normalized + 1),
truncated: false,
};
}
function listBudgetedChildDirectories(
dir: string,
budget: SkillDiscoveryBudget,
opts: { followSymlinks?: boolean; maxCandidateDirs: number },
): ChildDirectoryScan {
if (budget.remainingDirectoryScans <= 0 || budget.remainingRawEntries <= 0) {
budget.truncated = true;
return { dirs: [], scannedEntryCount: 0, truncated: false };
}
budget.remainingDirectoryScans -= 1;
const maxRawEntriesToScan = Math.min(
resolveRawEntryScanLimit(opts.maxCandidateDirs),
budget.remainingRawEntries,
);
const scan = listChildDirectories(dir, {
followSymlinks: opts.followSymlinks,
maxCandidateDirs: opts.maxCandidateDirs,
maxRawEntriesToScan,
});
budget.remainingRawEntries = Math.max(0, budget.remainingRawEntries - scan.scannedEntryCount);
budget.truncated ||= scan.truncated;
return scan;
}
function containsDiscoverableSkill(
dir: string,
opts: {
maxCandidateDirs: number;
maxSkillFileBytes?: number;
skipTopLevelDirName?: string;
},
): boolean {
const discoveryBudget = createSkillDiscoveryBudget(opts.maxCandidateDirs);
const queue: Array<{ dir: string; depth: number }> = [{ dir, depth: 0 }];
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index];
if (!candidate) {
continue;
}
if (candidate.depth > 0 && fs.existsSync(path.join(candidate.dir, "SKILL.md"))) {
if (hasLoadableSkillFrontmatter(dir, candidate.dir, opts.maxSkillFileBytes)) {
return true;
}
continue;
}
if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) {
continue;
}
if (
hasCandidateSymlinkChild(
candidate.dir,
candidate.depth === 0 ? opts.skipTopLevelDirName : undefined,
resolveRawEntryScanLimit(opts.maxCandidateDirs),
)
) {
return true;
}
const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, {
followSymlinks: false,
maxCandidateDirs: opts.maxCandidateDirs,
}).dirs;
for (const childDir of childDirs.toSorted().slice(0, opts.maxCandidateDirs)) {
if (candidate.depth === 0 && childDir === opts.skipTopLevelDirName) {
continue;
}
queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 });
}
}
return false;
}
function hasCandidateSymlinkChild(
dir: string,
skipName: string | undefined,
maxEntriesToScan: number,
): boolean {
const maxEntries = Math.max(0, maxEntriesToScan);
if (maxEntries === 0) {
return false;
}
let handle: fs.Dir | undefined;
try {
handle = fs.opendirSync(dir);
for (let scanned = 0; scanned < maxEntries; scanned += 1) {
const entry = handle.readSync();
if (!entry) {
break;
}
if (entry.name === skipName || entry.name.startsWith(".") || entry.name === "node_modules") {
continue;
}
if (entry.isSymbolicLink()) {
return true;
}
}
} catch {
return false;
} finally {
handle?.closeSync();
}
return false;
}
function hasLoadableSkillFrontmatter(
rootDir: string,
skillDir: string,
maxSkillFileBytes?: number,
): boolean {
const frontmatter = readSkillFrontmatterSafe({
rootDir,
filePath: path.join(skillDir, "SKILL.md"),
maxBytes: maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES,
});
const fallbackName = path.basename(skillDir).trim();
const name = frontmatter?.name?.trim() || fallbackName;
return !!name && !!frontmatter?.description?.trim();
}
function tryRealpath(filePath: string): string | null {
try {
return fs.realpathSync(filePath);
@@ -339,12 +482,17 @@ function resolveContainedSkillPath(params: {
return null;
}
function resolveNestedSkillsRoot(
export function resolveNestedSkillsRoot(
dir: string,
opts?: {
maxEntriesToScan?: number;
maxSkillFileBytes?: number;
},
): { baseDir: string; note?: string } {
if (hasLoadableSkillFrontmatter(dir, dir, opts?.maxSkillFileBytes)) {
return { baseDir: dir };
}
const rootSkillMdExists = fs.existsSync(path.join(dir, "SKILL.md"));
const nested = path.join(dir, "skills");
try {
if (!fs.existsSync(nested) || !fs.statSync(nested).isDirectory()) {
@@ -354,16 +502,41 @@ function resolveNestedSkillsRoot(
return { baseDir: dir };
}
// Heuristic: if `dir/skills/*/SKILL.md` exists for any entry, treat `dir/skills` as the real root.
// Note: don't stop at 25, but keep a cap to avoid pathological scans.
const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100);
const nestedDirs = listChildDirectories(nested, { maxCandidateDirs: scanLimit }).dirs;
if (
!rootSkillMdExists &&
containsDiscoverableSkill(dir, {
maxCandidateDirs: scanLimit,
maxSkillFileBytes: opts?.maxSkillFileBytes,
skipTopLevelDirName: "skills",
})
) {
return { baseDir: dir };
}
for (const name of nestedDirs) {
const skillMd = path.join(nested, name, "SKILL.md");
if (fs.existsSync(skillMd)) {
// Heuristic: if `dir/skills` contains any discoverable SKILL.md within the
// bounded skill depth, treat `dir/skills` as the real root. Use the same
// child-directory filter as discovery so ignored folders cannot re-root.
const discoveryBudget = createSkillDiscoveryBudget(scanLimit);
const queue: Array<{ dir: string; depth: number }> = [{ dir: nested, depth: 0 }];
for (let index = 0; index < queue.length; index += 1) {
const candidate = queue[index];
if (!candidate) {
continue;
}
if (hasLoadableSkillFrontmatter(nested, candidate.dir, opts?.maxSkillFileBytes)) {
return { baseDir: nested, note: `Detected nested skills root at ${nested}` };
}
if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) {
continue;
}
const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, {
followSymlinks: false,
maxCandidateDirs: scanLimit,
}).dirs;
for (const childDir of childDirs.toSorted().slice(0, scanLimit)) {
queue.push({ dir: path.join(candidate.dir, childDir), depth: candidate.depth + 1 });
}
}
return { baseDir: dir };
}
@@ -694,6 +867,7 @@ function loadSkillEntries(
const rootRealPath = tryRealpath(rootDir) ?? rootDir;
const resolved = resolveNestedSkillsRoot(params.dir, {
maxEntriesToScan: limits.maxCandidatesPerRoot,
maxSkillFileBytes: limits.maxSkillFileBytes,
});
const baseDir = resolved.baseDir;
const baseDirRealPath = resolveSkillRootCandidatePath({
@@ -744,13 +918,25 @@ function loadSkillEntries(
const maxCandidatesPerRoot = Math.max(0, limits.maxCandidatesPerRoot);
const maxSkillsLoadedPerSource = Math.max(0, limits.maxSkillsLoadedPerSource);
const childDirScan = listChildDirectories(baseDir, {
const nestedSkillsRootPath = path.resolve(baseDir, "skills");
const baseDirIsNestedSkillsRoot = path.resolve(baseDir) === path.resolve(rootDir, "skills");
const baseDirLooksLikeSkillsRoot = path.basename(baseDir) === "skills";
const discoveryBudget = createSkillDiscoveryBudget(maxCandidatesPerRoot);
const childDirScan = listBudgetedChildDirectories(baseDir, discoveryBudget, {
maxCandidateDirs: maxCandidatesPerRoot,
});
const childDirs = childDirScan.dirs;
const suspicious = childDirScan.truncated;
const sortedChildDirs = childDirs.toSorted();
const limitedChildren =
maxSkillsLoadedPerSource === 0 ? [] : childDirs.toSorted().slice(0, maxCandidatesPerRoot);
maxSkillsLoadedPerSource === 0 ? [] : sortedChildDirs.slice(0, maxCandidatesPerRoot);
if (
maxSkillsLoadedPerSource > 0 &&
sortedChildDirs.includes("skills") &&
!limitedChildren.includes("skills")
) {
limitedChildren.push("skills");
}
if (suspicious) {
skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", {
@@ -804,101 +990,119 @@ function loadSkillEntries(
);
};
// Consider immediate subfolders that look like skills (have SKILL.md) and are under size cap.
// When an immediate subfolder does NOT have a SKILL.md, check one level deeper for grouped
// skill directories (e.g. ~/.openclaw/skills/coze/koze-retrieval/SKILL.md).
for (const name of limitedChildren) {
const skillDir = path.join(baseDir, name);
const skillCandidates: CandidateSkillDir[] = [];
const scanQueue: Array<{ skillDir: string; name: string; depth: number }> = limitedChildren.map(
(name) => ({
skillDir: path.join(baseDir, name),
name,
depth: name === "skills" && !fs.existsSync(path.join(baseDir, name, "SKILL.md")) ? 0 : 1,
}),
);
for (let queueIndex = 0; queueIndex < scanQueue.length; queueIndex += 1) {
const candidate = scanQueue[queueIndex];
if (!candidate) {
continue;
}
const skillDirRealPath = resolveSkillRootCandidatePath({
source: params.source,
rootDir,
rootRealPath: baseDirRealPath,
candidatePath: skillDir,
candidatePath: candidate.skillDir,
allowedSymlinkTargetRealPaths,
});
if (!skillDirRealPath) {
continue;
}
const skillMd = path.join(skillDir, "SKILL.md");
const skillMd = path.join(candidate.skillDir, "SKILL.md");
if (fs.existsSync(skillMd)) {
const skillMdRealPath = resolveSkillFilePath({
source: params.source,
skillDir,
skillDir: candidate.skillDir,
skillDirRealPath,
candidatePath: skillMd,
});
if (skillMdRealPath) {
loadCandidateSkill({ skillDir, skillDirRealPath, name, skillMdRealPath });
}
} else {
// No SKILL.md here — check one level deeper for grouped skill directories.
// Apply the same per-root cap as the outer scan to avoid scanning huge nested trees.
const nestedChildScan = listChildDirectories(skillDir, {
maxCandidateDirs: maxCandidatesPerRoot,
});
const nestedChildren = nestedChildScan.dirs;
const nestedSuspicious = nestedChildScan.truncated;
if (nestedSuspicious) {
skillsLogger.warn(
"Nested skills directory looks suspiciously large, truncating discovery.",
{
dir: params.dir,
baseDir,
nestedDir: skillDir,
nestedChildDirCount: nestedChildren.length,
scannedEntryCount: nestedChildScan.scannedEntryCount,
maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot),
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
},
);
} else if (nestedChildren.length > maxCandidatesPerRoot) {
skillsLogger.warn("Nested skills directory has many entries, truncating discovery.", {
dir: params.dir,
baseDir,
nestedDir: skillDir,
nestedChildDirCount: nestedChildren.length,
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
skillCandidates.push({
skillDir: candidate.skillDir,
skillDirRealPath,
name: candidate.name,
skillMdRealPath,
});
}
const limitedNested = nestedChildren.toSorted().slice(0, maxCandidatesPerRoot);
for (const nestedName of limitedNested) {
const nestedDir = path.join(skillDir, nestedName);
const nestedSkillMd = path.join(nestedDir, "SKILL.md");
if (fs.existsSync(nestedSkillMd)) {
const nestedDirRealPath = resolveSkillRootCandidatePath({
source: params.source,
rootDir,
rootRealPath: baseDirRealPath,
candidatePath: nestedDir,
allowedSymlinkTargetRealPaths,
});
const nestedSkillMdRealPath = nestedDirRealPath
? resolveSkillFilePath({
source: params.source,
skillDir: nestedDir,
skillDirRealPath: nestedDirRealPath,
candidatePath: nestedSkillMd,
})
: null;
if (nestedDirRealPath && nestedSkillMdRealPath) {
loadCandidateSkill({
skillDir: nestedDir,
skillDirRealPath: nestedDirRealPath,
name: `${name}/${nestedName}`,
skillMdRealPath: nestedSkillMdRealPath,
});
}
}
if (loadedSkills.length >= maxSkillsLoadedPerSource) {
break;
}
}
continue;
}
const candidatePath = path.resolve(candidate.skillDir);
const maxGroupedDepth =
params.source === "openclaw-extra" &&
!baseDirIsNestedSkillsRoot &&
!baseDirLooksLikeSkillsRoot &&
candidatePath !== nestedSkillsRootPath &&
!isPathInside(nestedSkillsRootPath, candidatePath)
? MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH
: MAX_GROUPED_SKILL_SCAN_DEPTH;
if (candidate.depth >= maxGroupedDepth) {
continue;
}
const nestedChildScan = listBudgetedChildDirectories(candidate.skillDir, discoveryBudget, {
maxCandidateDirs: maxCandidatesPerRoot,
});
const nestedChildren = nestedChildScan.dirs;
const nestedSuspicious = nestedChildScan.truncated;
if (nestedSuspicious) {
skillsLogger.warn(
"Nested skills directory looks suspiciously large, truncating discovery.",
{
dir: params.dir,
baseDir,
nestedDir: candidate.skillDir,
nestedChildDirCount: nestedChildren.length,
scannedEntryCount: nestedChildScan.scannedEntryCount,
maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot),
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH,
},
);
} else if (nestedChildren.length > maxCandidatesPerRoot) {
skillsLogger.warn("Nested skills directory has many entries, truncating discovery.", {
dir: params.dir,
baseDir,
nestedDir: candidate.skillDir,
nestedChildDirCount: nestedChildren.length,
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH,
});
}
for (const nestedName of nestedChildren.toSorted().slice(0, maxCandidatesPerRoot)) {
scanQueue.push({
skillDir: path.join(candidate.skillDir, nestedName),
name: `${candidate.name}/${nestedName}`,
depth: candidate.depth + 1,
});
}
}
for (const candidate of skillCandidates.toSorted((a, b) => a.name.localeCompare(b.name))) {
if (loadedSkills.length >= maxSkillsLoadedPerSource) {
break;
}
loadCandidateSkill(candidate);
}
if (discoveryBudget.truncated) {
skillsLogger.warn("Skills root hit recursive discovery budget, truncating discovery.", {
dir: params.dir,
baseDir,
maxCandidatesPerRoot: limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH,
});
}
if (loadedSkills.length > maxSkillsLoadedPerSource) {

View File

@@ -4,13 +4,14 @@ import { describe, expect, it } from "vitest";
import { execNodeEvalSync } from "../test-utils/node-process.js";
describe("plugin SDK fetch runtime", () => {
it("does not initialize the undici global dispatcher on import", () => {
it("does not replace the undici global dispatcher on import", () => {
const moduleUrl = pathToFileURL(path.resolve("src/plugin-sdk/fetch-runtime.ts")).href;
const source = `
const dispatcherKey = Symbol.for("undici.globalDispatcher.1");
const { getGlobalDispatcher } = await import("undici");
const before = getGlobalDispatcher();
await import(${JSON.stringify(moduleUrl)});
if (globalThis[dispatcherKey] !== undefined) {
throw new Error("undici global dispatcher was initialized");
if (getGlobalDispatcher() !== before) {
throw new Error("undici global dispatcher was replaced");
}
console.log("ok");
`;