fix(skills): read node-hosted skills from their publishing node (#104832)

* fix(skills): make node skill locators readable

* test(skills): track node skill temp directory

* chore: defer node skill changelog to release process
This commit is contained in:
Peter Steinberger
2026-07-11 17:54:14 -07:00
committed by GitHub
parent d05391b9b3
commit 0d84eb4e70
14 changed files with 314 additions and 23 deletions
+7 -2
View File
@@ -203,8 +203,13 @@ node skill files; the node host does not watch the skills directory.
Node-hosted skill entries identify their node and carry their execution
location. Skill files, referenced relative paths, and binaries remain on that
node. Load instructions and run commands with
`exec host=node node=<node-id>` so relative paths resolve on the node rather
node. The agent reads the advertised `node://.../SKILL.md` location with the
normal `read` tool. `file_fetch` accepts operator-approved absolute node paths,
not node skill locators; runtimes without the normal read tool can instead run
`cat SKILL.md` through `exec host=node node=<node-id>` with the advertised
`node://.../skills/<name>` directory as `workdir`. Referenced files and binaries
use the same exec target and workdir. The node host resolves that locator against
its active OpenClaw state directory, so relative paths resolve on the node rather
than the Gateway machine. The publishing node must have approved `system.run`,
and the agent's exec policy must allow `host=node`; otherwise the skill stays
out of that agent's snapshot.
@@ -977,6 +977,41 @@ describe("before_tool_call loop detection behavior", () => {
});
});
it("emits skill usage diagnostics for node skill locators", async () => {
const locator = "node://node-1/skills/remote-skill/SKILL.md";
const execute = vi.fn().mockResolvedValue({ content: [{ type: "text", text: "skill" }] });
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any, {
agentId: "main",
sessionKey: "session-key",
skillsSnapshot: {
prompt: "",
skills: [{ name: "remote-skill" }],
resolvedSkills: [
createCanonicalFixtureSkill({
name: "remote-skill",
description: "Remote skill",
filePath: locator,
baseDir: "node://node-1/skills/remote-skill",
source: "openclaw-node",
}),
],
},
loopDetection: { enabled: false },
});
await withSkillUsageDiagnosticEvents(async (emitted, _privateData, flush) => {
await tool.execute("tool-call-node-skill", { path: locator }, undefined, undefined);
await flush();
expectEventFields(emitted[1], {
type: "skill.used",
skillName: "remote-skill",
activation: "read",
toolName: "read",
});
});
});
it("accounts sandbox skill reads against the original canonical file", async () => {
const workspaceDir = "/workspace";
const readPath = "/workspace/.openclaw/sandbox-skills/skills/demo/SKILL.md";
+9 -2
View File
@@ -638,6 +638,9 @@ function resolveRelativeToolPath(candidate: string, ctx?: HookContext): string |
if (!trimmed) {
return undefined;
}
if (trimmed.startsWith("node://")) {
return trimmed;
}
if (trimmed === "~") {
return os.homedir();
}
@@ -670,8 +673,12 @@ function skillInstructionPaths(snapshot: SkillSnapshot | undefined): Map<string,
}
const match = resolvedSkillUsageMatch({ activation: "read", skill });
const filePath = typeof skill.filePath === "string" ? skill.filePath.trim() : "";
if (filePath && path.isAbsolute(filePath)) {
matches.set(path.resolve(filePath), match);
if (filePath) {
if (filePath.startsWith("node://")) {
matches.set(filePath, match);
} else if (path.isAbsolute(filePath)) {
matches.set(path.resolve(filePath), match);
}
}
const baseDir = typeof skill.baseDir === "string" ? skill.baseDir.trim() : "";
if (baseDir && path.isAbsolute(baseDir)) {
@@ -10,7 +10,11 @@ import type { AgentTool, AgentToolResult } from "openclaw/plugin-sdk/agent-core"
import { Type } from "typebox";
import { describe, expect, it, vi } from "vitest";
import * as windowsEncoding from "../infra/windows-encoding.js";
import { createOpenClawReadTool, createSandboxedReadTool } from "./agent-tools.read.js";
import {
createOpenClawReadTool,
createSandboxedReadTool,
wrapReadToolWithSkillContent,
} from "./agent-tools.read.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
function extractToolText(result: unknown): string {
@@ -33,6 +37,28 @@ function extractToolText(result: unknown): string {
}
describe("createOpenClawCodingTools read behavior", () => {
it("reads exact node skill locators without sending them to the filesystem backend", async () => {
const locator = "node://node-1/skills/pond/SKILL.md";
const execute = vi.fn(async () => {
throw new Error("filesystem backend should not run");
});
const tool = wrapReadToolWithSkillContent(
{
name: "read",
label: "read",
description: "read a file",
parameters: {},
execute,
} as never,
[{ filePath: locator, readContent: "# Pond\nremote-marker" }],
);
const result = await tool.execute("node-skill-read", { path: locator });
expect(extractToolText(result)).toContain("remote-marker");
expect(execute).not.toHaveBeenCalled();
});
it("uses host decoding only for host-backed sandbox paths", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-encoding-"));
await fs.writeFile(path.join(tmpDir, "notes.txt"), "hello", "utf8");
@@ -158,6 +158,40 @@ function cronCreatorToolNames(
}
describe("createOpenClawCodingTools", () => {
it("reads node-hosted skill content through the assembled workspace-only read tool", async () => {
const locator = "node://node-1/skills/pond/SKILL.md";
const tools = createOpenClawCodingTools({
config: { tools: { fs: { workspaceOnly: true } } },
skillsSnapshot: {
prompt: "",
skills: [{ name: "pond" }],
resolvedSkills: [
{
name: "pond",
description: "Pond skill",
filePath: locator,
baseDir: "node://node-1/skills/pond",
readContent: "# Pond\nassembled-marker",
source: "openclaw-node",
sourceInfo: {
source: "openclaw-node",
path: locator,
scope: "temporary",
origin: "top-level",
},
disableModelInvocation: false,
},
],
},
});
const result = await requireTool(tools, "read").execute("node-skill-read", {
path: locator,
});
expect(JSON.stringify(result)).toContain("assembled-marker");
});
const testConfig: OpenClawConfig = {};
afterEach(() => {
+55
View File
@@ -65,6 +65,11 @@ type OpenClawReadToolOptions = {
imageSanitization?: ImageSanitizationLimits;
};
type SkillReadContent = {
filePath: string;
readContent?: string;
};
type ReadTruncationDetails = {
truncated: boolean;
outputLines: number;
@@ -906,6 +911,56 @@ export function createOpenClawReadTool(
};
}
/** Serve exact non-filesystem skill locators before workspace path guards run. */
export function wrapReadToolWithSkillContent(
tool: AnyAgentTool,
skills: readonly SkillReadContent[] | undefined,
options?: OpenClawReadToolOptions,
): AnyAgentTool {
const contentByPath = new Map(
(skills ?? []).flatMap((skill) =>
skill.filePath.startsWith("node://") && typeof skill.readContent === "string"
? [[skill.filePath, skill.readContent] as const]
: [],
),
);
if (contentByPath.size === 0) {
return tool;
}
const readContent = (filePath: string): string => {
const content = contentByPath.get(filePath);
if (content === undefined) {
throw Object.assign(new Error(`Virtual skill file not found: ${filePath}`), {
code: "ENOENT",
});
}
return content;
};
const virtualBase = createReadTool("/", {
operations: {
resolvePath: (filePath) => filePath,
access: async (filePath) => void readContent(filePath),
readFile: async (filePath) => Buffer.from(readContent(filePath), "utf8"),
},
}) as unknown as AnyAgentTool;
const virtualRead = createOpenClawReadTool(virtualBase, options);
return {
...tool,
execute: async (toolCallId, args, signal, onUpdate) => {
const record = getToolParamsRecord(args);
const rawPath = record?.path;
const normalizedPath =
typeof rawPath === "string" ? normalizeFileToolPathParam(rawPath) : undefined;
if (normalizedPath && contentByPath.has(normalizedPath)) {
const virtualArgs =
normalizedPath === rawPath || !record ? args : { ...record, path: normalizedPath };
return virtualRead.execute(toolCallId, virtualArgs, signal, onUpdate);
}
return tool.execute(toolCallId, args, signal, onUpdate);
},
};
}
function createSandboxReadOperations(params: SandboxToolParams) {
return {
resolvePath: (filePath: string) => {
+20 -11
View File
@@ -44,6 +44,7 @@ import {
createSandboxedEditTool,
createSandboxedReadTool,
createSandboxedWriteTool,
wrapReadToolWithSkillContent,
getToolParamsRecord,
wrapToolMemoryFlushAppendOnlyWrite,
wrapToolWorkspaceRootGuard,
@@ -702,13 +703,17 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
modelContextWindowTokens: options?.modelContextWindowTokens,
imageSanitization,
});
const guarded = workspaceOnly
? wrapToolWorkspaceRootGuardWithOptions(sandboxed, sandboxRoot, {
additionalContainerMounts: readOnlySandboxReadMounts(sandbox),
containerWorkdir: sandbox.containerWorkdir,
})
: sandboxed;
base.push(
workspaceOnly
? wrapToolWorkspaceRootGuardWithOptions(sandboxed, sandboxRoot, {
additionalContainerMounts: readOnlySandboxReadMounts(sandbox),
containerWorkdir: sandbox.containerWorkdir,
})
: sandboxed,
wrapReadToolWithSkillContent(guarded, options?.skillsSnapshot?.resolvedSkills, {
modelContextWindowTokens: options?.modelContextWindowTokens,
imageSanitization,
}),
);
continue;
}
@@ -717,12 +722,16 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
modelContextWindowTokens: options?.modelContextWindowTokens,
imageSanitization,
});
const guarded = workspaceOnly
? wrapToolWorkspaceRootGuardWithOptions(wrapped, codingRoot, {
additionalRoots: skillReadRoots,
})
: wrapped;
base.push(
workspaceOnly
? wrapToolWorkspaceRootGuardWithOptions(wrapped, codingRoot, {
additionalRoots: skillReadRoots,
})
: wrapped,
wrapReadToolWithSkillContent(guarded, options?.skillsSnapshot?.resolvedSkills, {
modelContextWindowTokens: options?.modelContextWindowTokens,
imageSanitization,
}),
);
continue;
}
+41 -1
View File
@@ -2,13 +2,16 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { GatewayClient } from "../gateway/client.js";
import { saveExecApprovals, type ExecApprovalsSnapshot } from "../infra/exec-approvals.js";
import { withEnvAsync } from "../test-utils/env.js";
import type { SkillBinsProvider } from "./invoke-types.js";
import { handleInvoke } from "./invoke.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const approvalResolutionFailure = vi.hoisted(() => ({ error: null as Error | null }));
type ExecApprovalsUpdate = Parameters<
typeof import("../infra/exec-approvals.js").updateExecApprovals
@@ -400,6 +403,43 @@ describe("node host invoke", () => {
},
);
it.runIf(process.platform !== "win32")(
"resolves node skill cwd locators before preparing system.run",
async () => {
const stateDir = fs.realpathSync(tempDirs.make("openclaw-node-skill-cwd-"));
const skillDir = path.join(stateDir, "skills", "cwd-skill");
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(
path.join(skillDir, "SKILL.md"),
"---\nname: cwd-skill\ndescription: Cwd skill\n---\n",
);
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const request = vi.fn<GatewayClient["request"]>().mockResolvedValue(null);
const skillBins: SkillBinsProvider = { current: async () => [] };
await handleInvoke(
{
id: "invoke-skill-cwd",
nodeId: "node-1",
command: "system.run.prepare",
paramsJSON: JSON.stringify({
command: ["/bin/pwd"],
cwd: "node://node-1/skills/cwd-skill",
}),
},
{ request } as unknown as GatewayClient,
skillBins,
);
const result = request.mock.calls[0]?.[1] as { payloadJSON?: string } | undefined;
const payload = JSON.parse(result?.payloadJSON ?? "{}") as {
plan?: { cwd?: string };
};
expect(payload.plan?.cwd).toBe(fs.realpathSync(skillDir));
});
},
);
it.runIf(process.platform !== "win32")(
"keeps prepared allow-always coverage incomplete when any planned command is prompt-only",
async () => {
+19 -2
View File
@@ -58,6 +58,7 @@ import type {
} from "./invoke-types.js";
import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js";
import { invokeRegisteredNodeHostCommand } from "./plugin-node-host.js";
import { resolveNodeHostedSkillDirectory } from "./skills.js";
const OUTPUT_CAP = 200_000;
const MCP_TEXT_CONTENT_MAX_BYTES = 1024 * 1024;
@@ -110,6 +111,16 @@ type SystemRunPrepareEnv =
message: string;
};
function resolveNodeSkillCwdParam<T extends { cwd?: unknown }>(params: T, nodeId: string): T {
if (typeof params.cwd !== "string") {
return params;
}
// Resolve before approval planning so the plan, policy, and spawn all bind
// the same canonical node-local directory instead of trusting a URI at exec time.
const resolved = resolveNodeHostedSkillDirectory(params.cwd, nodeId);
return resolved ? { ...params, cwd: resolved } : params;
}
function buildEnvOverrideRejectionMessage(params: {
rejectedOverrideBlockedKeys: string[];
rejectedOverrideInvalidKeys: string[];
@@ -770,7 +781,10 @@ async function dispatchInvoke(
if (command === "system.run.prepare") {
try {
const params = decodeParams<SystemRunPrepareParams>(frame.paramsJSON);
const params = resolveNodeSkillCwdParam(
decodeParams<SystemRunPrepareParams>(frame.paramsJSON),
frame.nodeId,
);
const prepared = buildSystemRunApprovalPlan(params);
if (!prepared.ok) {
await sendErrorResult(client, frame, "INVALID_REQUEST", prepared.message);
@@ -826,7 +840,10 @@ async function dispatchInvoke(
let params: SystemRunParams;
try {
params = decodeParams<SystemRunParams>(frame.paramsJSON);
params = resolveNodeSkillCwdParam(
decodeParams<SystemRunParams>(frame.paramsJSON),
frame.nodeId,
);
} catch (err) {
await sendInvalidRequestResult(client, frame, err);
return;
+26 -1
View File
@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { scanNodeHostedSkills } from "./skills.js";
import { resolveNodeHostedSkillDirectory, scanNodeHostedSkills } from "./skills.js";
const roots: string[] = [];
@@ -33,6 +33,31 @@ afterEach(() => {
});
describe("scanNodeHostedSkills", () => {
it("resolves a matching node skill locator against a custom state directory", () => {
const stateDir = createRoot();
const skillDir = path.join(stateDir, "skills", "profile-skill");
writeSkill(path.join(stateDir, "skills"), "profile-skill", "Profile skill");
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
expect(resolveNodeHostedSkillDirectory("node://node-1/skills/profile-skill", "node-1")).toBe(
fs.realpathSync(skillDir),
);
expect(() =>
resolveNodeHostedSkillDirectory("node://node-2/skills/profile-skill", "node-1"),
).toThrow("invalid for this node");
expect(() =>
resolveNodeHostedSkillDirectory("node://node-1/skills/../profile-skill", "node-1"),
).toThrow("invalid for this node");
if (process.platform !== "win32") {
const outsideDir = path.join(stateDir, "outside");
writeSkill(stateDir, "outside", "Outside");
fs.symlinkSync(outsideDir, path.join(stateDir, "skills", "escape"));
expect(() =>
resolveNodeHostedSkillDirectory("node://node-1/skills/escape", "node-1"),
).toThrow("unavailable");
}
});
it("uses the active OpenClaw profile skills directory by default", () => {
const stateDir = createRoot();
const content = writeSkill(path.join(stateDir, "skills"), "profile-skill", "Profile skill");
+26
View File
@@ -1,6 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import type { NodeSkillDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { isPathInside } from "../infra/path-guards.js";
import {
NODE_SKILL_MAX_CONTENT_BYTES,
NODE_SKILL_MAX_COUNT,
@@ -16,6 +17,31 @@ type ScanNodeHostedSkillsOptions = {
warn?: (message: string) => void;
};
/** Resolve an advertised node skill directory locator to this node's canonical path. */
export function resolveNodeHostedSkillDirectory(locator: string, nodeId: string): string | null {
if (!locator.startsWith("node://")) {
return null;
}
const prefix = `node://${encodeURIComponent(nodeId)}/skills/`;
const name = locator.startsWith(prefix) ? locator.slice(prefix.length) : "";
if (!NODE_SKILL_NAME_RE.test(name)) {
throw new Error("INVALID_REQUEST: node skill cwd locator is invalid for this node");
}
try {
const skillsDir = fs.realpathSync(path.join(resolveConfigDir(), "skills"));
const skillDir = fs.realpathSync(path.join(skillsDir, name));
if (
!isPathInside(skillsDir, skillDir) ||
!fs.statSync(path.join(skillDir, "SKILL.md")).isFile()
) {
throw new Error("missing SKILL.md");
}
return skillDir;
} catch {
throw new Error("INVALID_REQUEST: node skill cwd locator is unavailable");
}
}
function listCandidateSkillFiles(skillsDir: string, warn: (message: string) => void): string[] {
let entries: fs.Dirent[];
try {
+2
View File
@@ -6,6 +6,8 @@ export interface Skill {
description: string;
/** Additional loading guidance rendered with the location in full and compact catalogs. */
locationNote?: string;
/** Runtime-only content for non-filesystem skill locators such as node://. */
readContent?: string;
filePath: string;
baseDir: string;
/** Deterministic marker for the SKILL.md content rendered as <version>. */
+10 -2
View File
@@ -65,10 +65,18 @@ describe("node-hosted skill snapshots", () => {
const snapshot = buildWorkspaceSkillSnapshot("/workspace", { entries });
expect(snapshot.skills.map((skill) => skill.name)).toEqual(["release-helper"]);
expect(snapshot.prompt).toContain("Build Mac (node-1)");
expect(snapshot.prompt).toContain(
"Read this SKILL.md with the normal read tool at its exact node:// location",
);
expect(snapshot.prompt).toContain("do not use file_fetch");
expect(snapshot.prompt).toContain("to run cat SKILL.md");
expect(snapshot.prompt).toContain("exec host=node node=node-1");
expect(snapshot.prompt).toContain("skills/release-helper/ in its OpenClaw state dir");
expect(snapshot.prompt).toContain("relative paths resolve on the node");
expect(snapshot.prompt).toContain("workdir=node://node-1/skills/release-helper");
expect(snapshot.prompt).toContain("node host resolves that locator");
expect(snapshot.prompt).toContain("node://node-1/skills/release-helper/SKILL.md");
expect(snapshot.resolvedSkills?.[0]?.readContent).toBe(
content("release-helper", "Prepare a release"),
);
expect(getSkillsSnapshotVersion()).toBeGreaterThan(before);
const connectedVersion = getSkillsSnapshotVersion();
+3 -1
View File
@@ -158,7 +158,8 @@ function remoteSkillLocation(nodeId: string, name: string): string {
function locatorNote(node: RemoteSkillNode, skillName: string): string {
const label = node.displayName?.trim() || node.nodeId;
return `Node-hosted on ${label} (${node.nodeId}): files and bins live on that node under skills/${skillName}/ in its OpenClaw state dir. Run every command via exec host=node node=${node.nodeId}; relative paths resolve on the node.`;
const cwd = remoteSkillLocation(node.nodeId, skillName).slice(0, -"/SKILL.md".length);
return `Node-hosted on ${label} (${node.nodeId}). Read this SKILL.md with the normal read tool at its exact node:// location; do not use file_fetch, which only accepts approved absolute node paths. If read is unavailable, use exec host=node node=${node.nodeId} with workdir=${cwd} to run cat SKILL.md. Run referenced files and bins with the same exec target and workdir; the node host resolves that locator to the node-local skill directory.`;
}
export function mergeRemoteNodeSkillEntries(
@@ -215,6 +216,7 @@ export function mergeRemoteNodeSkillEntries(
name: exposedName,
description: skill.description,
locationNote: locatorNote(node, skill.name),
readContent: skill.content,
filePath,
baseDir: filePath.slice(0, -"/SKILL.md".length),
promptVersion: computeSkillPromptVersion(skill.content),