File size: 7,455 Bytes
253e783 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | 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 });
}
}
});
});
|