openclaw / src /node-host /claude-skill-session.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
253e783 verified
Raw History Blame Contribute Delete
7.46 kB
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { loadWorkspaceSkills } from "../skills/loading/workspace-skill-loader.js";
import { buildSkillSnapshot } from "../skills/loading/workspace-skill-prompt.js";
import { prepareSkillResourceDelivery } from "../skills/runtime/resources.js";
import { prepareNodeClaudeSkillSession } from "./claude-skill-session.js";
const temps = useAutoCleanupTempDirTracker(afterEach);
describe("node Claude skill artifact cleanup", () => {
it.skipIf(process.platform === "win32" || process.getuid?.() === 0).each([false, true])(
"grants only the artifact root and cleans independent inputs after close (Workshop: %s)",
async (workshop) => {
const workspace = temps.make("node-skill-session-");
const skillDir = path.join(workspace, "skills", "guide");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
"---\ndescription: Guide\n---\n# Guide\n",
);
const resources = await prepareSkillResourceDelivery(
await buildSkillSnapshot(workspace, {
entries: loadWorkspaceSkills(workspace, { workspaceOnly: true }),
}),
() => {},
);
const session = await prepareNodeClaudeSkillSession({
signal: new AbortController().signal,
emitChunk: vi.fn(),
onInput: vi.fn(),
frames: {
send: vi.fn(),
onMessage: (listener) => {
void listener(
Buffer.from(
JSON.stringify({
type: "init",
resources,
...(workshop ? { workshop: { description: "Fixture", inputSchema: {} } } : {}),
}),
),
);
return vi.fn();
},
},
});
const artifactRoot = session.argv[session.argv.indexOf("--add-dir") + 1]!;
const skillPath = session.rewriteReferences(path.join(skillDir, "SKILL.md"));
const configPath = workshop
? session.argv[session.argv.indexOf("--mcp-config") + 1]!
: undefined;
const warn = vi.fn();
const previousConsole = loggingState.rawConsole;
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
loggingState.rawConsole = { log: vi.fn(), info: vi.fn(), warn, error: vi.fn() };
try {
expect(session.argv.filter((arg) => arg === "--add-dir")).toHaveLength(1);
expect(path.dirname(path.dirname(skillPath))).toBe(artifactRoot);
expect((await fs.stat(artifactRoot)).mode & 0o777).toBe(0o700);
expect(session.catalog).toContain(skillPath);
if (configPath) {
expect(path.relative(artifactRoot, configPath).startsWith(`..${path.sep}`)).toBe(true);
expect((await fs.stat(path.dirname(configPath))).mode & 0o777).toBe(0o700);
}
await fs.chmod(path.dirname(skillPath), 0o500);
await session.close();
await expect(session.writeStdout("late output")).rejects.toThrow("invocation closed");
await expect(session.cleanup()).resolves.toBeUndefined();
expect(await fs.readFile(skillPath, "utf8")).toContain("# Guide");
if (configPath) {
await expect(fs.stat(path.dirname(configPath))).rejects.toMatchObject({ code: "ENOENT" });
}
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls.flat().join("\n")).toContain(artifactRoot);
} finally {
await session.close();
await fs.chmod(path.dirname(skillPath), 0o700);
await session.cleanup();
loggingState.rawConsole = previousConsole;
setLoggerOverride(null);
resetLogger();
}
},
);
it("preserves the exact frozen setup error when partial skill cleanup fails", async () => {
const workspace = temps.make("node-skill-rollback-");
const skillDir = path.join(workspace, "skills", "partial");
await fs.mkdir(skillDir, { recursive: true });
const markdown = "---\ndescription: Partial materialization proof\n---\n# Instructions\n";
await fs.writeFile(path.join(skillDir, "SKILL.md"), markdown);
await fs.writeFile(path.join(skillDir, "reference.md"), "supporting resource");
const resources = await prepareSkillResourceDelivery(
await buildSkillSnapshot(workspace, {
entries: loadWorkspaceSkills(workspace, { workspaceOnly: true }),
}),
() => {},
);
const primary = new Error("Node skill setup cancelled");
primary.name = "AbortError";
Object.freeze(primary);
const controller = new AbortController();
let directory: string | undefined;
let retainedFile: string | undefined;
const originalWriteFile = fs.writeFile;
const writeFile = vi
.spyOn(fs, "writeFile")
.mockImplementation(async (target, data, options) => {
await originalWriteFile(target, data, options);
if (typeof target === "string" && path.basename(target) === "SKILL.md") {
retainedFile = target;
directory = path.dirname(path.dirname(target));
controller.abort(primary);
}
});
const originalRm = fs.rm;
const deletionError = new Error("EACCES: retained node skill artifact");
const rm = vi.spyOn(fs, "rm").mockImplementation((target, options) => {
if (
directory &&
(String(target) === directory || String(target).startsWith(`${directory}${path.sep}`))
) {
return Promise.reject(deletionError);
}
return originalRm(target, options);
});
const warn = vi.fn();
const previousConsole = loggingState.rawConsole;
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
loggingState.rawConsole = { log: vi.fn(), info: vi.fn(), warn, error: vi.fn() };
const unsubscribe = vi.fn();
try {
const result = await prepareNodeClaudeSkillSession({
signal: controller.signal,
emitChunk: vi.fn(),
onInput: vi.fn(),
frames: {
send: vi.fn(),
onMessage: (listener) => {
void listener(Buffer.from(JSON.stringify({ type: "init", resources })));
return unsubscribe;
},
},
}).catch((error: unknown) => error);
expect.soft(result).toBe(primary);
expect(unsubscribe).toHaveBeenCalledOnce();
expect(retainedFile).toBeDefined();
expect(await fs.readFile(retainedFile!, "utf8")).toBe(markdown);
await expect(
fs.stat(path.join(path.dirname(retainedFile!), "reference.md")),
).rejects.toMatchObject({ code: "ENOENT" });
const warning = warn.mock.calls.flat().map(String).join("\n");
expect(warning).toContain("Materialized skill cleanup failed");
expect(warn).toHaveBeenCalledOnce();
expect(warning).toContain(directory);
expect(warning).toContain("EACCES");
expect(warning).not.toContain(markdown);
} finally {
writeFile.mockRestore();
rm.mockRestore();
loggingState.rawConsole = previousConsole;
setLoggerOverride(null);
resetLogger();
if (directory) {
await fs.rm(directory, { recursive: true, force: true });
}
}
});
});