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 });
      }
    }
  });
});