mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
committed by
GitHub
parent
4b8c260444
commit
43e243f436
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user