skyloom 1.16.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +17 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/cli/main.js +37 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/core/agent.d.ts +2 -0
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +13 -0
- package/dist/core/agent.js.map +1 -1
- package/dist/core/bgproc.d.ts +59 -0
- package/dist/core/bgproc.d.ts.map +1 -0
- package/dist/core/bgproc.js +135 -0
- package/dist/core/bgproc.js.map +1 -0
- package/dist/core/commands.d.ts.map +1 -1
- package/dist/core/commands.js +20 -0
- package/dist/core/commands.js.map +1 -1
- package/dist/core/diagnostics.d.ts +39 -0
- package/dist/core/diagnostics.d.ts.map +1 -0
- package/dist/core/diagnostics.js +206 -0
- package/dist/core/diagnostics.js.map +1 -0
- package/dist/core/diff.d.ts +31 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +82 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/envcontext.d.ts +25 -0
- package/dist/core/envcontext.d.ts.map +1 -0
- package/dist/core/envcontext.js +112 -0
- package/dist/core/envcontext.js.map +1 -0
- package/dist/core/factory.d.ts +2 -0
- package/dist/core/factory.d.ts.map +1 -1
- package/dist/core/factory.js +35 -2
- package/dist/core/factory.js.map +1 -1
- package/dist/core/sandbox.d.ts +1 -0
- package/dist/core/sandbox.d.ts.map +1 -1
- package/dist/core/sandbox.js +1 -0
- package/dist/core/sandbox.js.map +1 -1
- package/dist/core/security.d.ts +22 -2
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +54 -24
- package/dist/core/security.js.map +1 -1
- package/dist/core/skill.d.ts +4 -0
- package/dist/core/skill.d.ts.map +1 -1
- package/dist/core/skill.js +1 -0
- package/dist/core/skill.js.map +1 -1
- package/dist/core/subagent.d.ts +75 -0
- package/dist/core/subagent.d.ts.map +1 -0
- package/dist/core/subagent.js +287 -0
- package/dist/core/subagent.js.map +1 -0
- package/dist/core/tool.d.ts +15 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +88 -30
- package/dist/core/tool.js.map +1 -1
- package/dist/plugins/loader.d.ts +49 -8
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +129 -16
- package/dist/plugins/loader.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +118 -13
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/spawn.d.ts +23 -0
- package/dist/tools/spawn.d.ts.map +1 -0
- package/dist/tools/spawn.js +77 -0
- package/dist/tools/spawn.js.map +1 -0
- package/docs/OPTIMIZATION_PLAN.md +21 -4
- package/package.json +1 -1
- package/src/cli/loom_chat.ts +11 -0
- package/src/cli/main.ts +31 -1
- package/src/core/agent.ts +13 -0
- package/src/core/bgproc.ts +153 -0
- package/src/core/commands.ts +20 -0
- package/src/core/diagnostics.ts +178 -0
- package/src/core/diff.ts +98 -0
- package/src/core/envcontext.ts +79 -0
- package/src/core/factory.ts +31 -2
- package/src/core/sandbox.ts +1 -1
- package/src/core/security.ts +62 -21
- package/src/core/skill.ts +1 -1
- package/src/core/subagent.ts +272 -0
- package/src/core/tool.ts +86 -31
- package/src/plugins/loader.ts +145 -18
- package/src/tools/builtin.ts +107 -13
- package/src/tools/spawn.ts +92 -0
- package/tests/bgproc.test.ts +65 -0
- package/tests/diagnostics.test.ts +86 -0
- package/tests/edit_diff.test.ts +102 -0
- package/tests/envcontext.test.ts +67 -0
- package/tests/plugins.test.ts +84 -0
- package/tests/security.test.ts +87 -0
- package/tests/subagent.test.ts +211 -0
- package/tests/tool.test.ts +76 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getBackgroundManager } from "../src/core/bgproc";
|
|
3
|
+
|
|
4
|
+
const NODE = `"${process.execPath}"`;
|
|
5
|
+
|
|
6
|
+
async function waitFor(pred: () => boolean, timeoutMs = 5000): Promise<boolean> {
|
|
7
|
+
const t0 = Date.now();
|
|
8
|
+
while (Date.now() - t0 < timeoutMs) {
|
|
9
|
+
if (pred()) return true;
|
|
10
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
11
|
+
}
|
|
12
|
+
return pred();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("bgproc · background process manager", () => {
|
|
16
|
+
it("runs a command to completion and captures its output", async () => {
|
|
17
|
+
const mgr = getBackgroundManager();
|
|
18
|
+
const { id, error } = mgr.start(`${NODE} -e "process.stdout.write('BGHELLO')"`);
|
|
19
|
+
expect(error).toBeUndefined();
|
|
20
|
+
expect(id).toBeTruthy();
|
|
21
|
+
|
|
22
|
+
const done = await waitFor(() => mgr.get(id!)?.status !== "running");
|
|
23
|
+
expect(done).toBe(true);
|
|
24
|
+
await new Promise((r) => setTimeout(r, 50)); // let final stdout flush
|
|
25
|
+
|
|
26
|
+
const r = mgr.read(id!);
|
|
27
|
+
expect(r.ok).toBe(true);
|
|
28
|
+
expect(r.text).toContain("BGHELLO");
|
|
29
|
+
expect(mgr.get(id!)?.status).toBe("exited");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("incremental read advances the cursor (second read is empty)", async () => {
|
|
33
|
+
const mgr = getBackgroundManager();
|
|
34
|
+
const { id } = mgr.start(`${NODE} -e "process.stdout.write('ONCE')"`);
|
|
35
|
+
await waitFor(() => mgr.get(id!)?.status !== "running");
|
|
36
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
37
|
+
expect(mgr.read(id!).text).toContain("ONCE");
|
|
38
|
+
expect(mgr.read(id!).text).toBe(""); // already consumed
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("lists jobs and kills a long-running one", async () => {
|
|
42
|
+
const mgr = getBackgroundManager();
|
|
43
|
+
const { id } = mgr.start(`${NODE} -e "setInterval(()=>{},1000)"`);
|
|
44
|
+
expect(mgr.list().some((j) => j.id === id)).toBe(true);
|
|
45
|
+
expect(mgr.get(id!)?.status).toBe("running");
|
|
46
|
+
|
|
47
|
+
const k = mgr.kill(id!);
|
|
48
|
+
expect(k.ok).toBe(true);
|
|
49
|
+
const killed = await waitFor(() => mgr.get(id!)?.status === "killed");
|
|
50
|
+
expect(killed).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("blocks red-line commands before spawning", () => {
|
|
54
|
+
const mgr = getBackgroundManager();
|
|
55
|
+
const { id, error } = mgr.start("rm -rf /");
|
|
56
|
+
expect(id).toBeUndefined();
|
|
57
|
+
expect(error).toMatch(/BLOCKED|REDLINE/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("errors on read/kill of an unknown job", () => {
|
|
61
|
+
const mgr = getBackgroundManager();
|
|
62
|
+
expect(mgr.read("nope").ok).toBe(false);
|
|
63
|
+
expect(mgr.kill("nope").ok).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import {
|
|
6
|
+
getDiagnostics,
|
|
7
|
+
getTypeScriptDiagnostics,
|
|
8
|
+
parseDiagnosticOutput,
|
|
9
|
+
formatDiagnostics,
|
|
10
|
+
} from "../src/core/diagnostics";
|
|
11
|
+
|
|
12
|
+
describe("diagnostics · TypeScript compiler API", () => {
|
|
13
|
+
let dir: string;
|
|
14
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-diag-")); });
|
|
15
|
+
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
16
|
+
|
|
17
|
+
function write(name: string, content: string): string {
|
|
18
|
+
const p = path.join(dir, name);
|
|
19
|
+
fs.writeFileSync(p, content);
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it("reports a type error with line:col and TS code", () => {
|
|
24
|
+
const p = write("bad.ts", "const x: number = 'hello';\n");
|
|
25
|
+
const res = getTypeScriptDiagnostics(p);
|
|
26
|
+
expect(Array.isArray(res)).toBe(true);
|
|
27
|
+
const diags = res as any[];
|
|
28
|
+
expect(diags.length).toBeGreaterThan(0);
|
|
29
|
+
const err = diags[0];
|
|
30
|
+
expect(err.severity).toBe("error");
|
|
31
|
+
expect(err.line).toBe(1);
|
|
32
|
+
expect(err.code).toMatch(/^TS\d+/);
|
|
33
|
+
expect(err.source).toBe("ts");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns an empty array for a clean file", () => {
|
|
37
|
+
const p = write("ok.ts", "export const y: number = 5;\n");
|
|
38
|
+
const res = getTypeScriptDiagnostics(p);
|
|
39
|
+
expect(Array.isArray(res)).toBe(true);
|
|
40
|
+
expect((res as any[]).length).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("getDiagnostics dispatches TS files to the compiler API", () => {
|
|
44
|
+
const p = write("d.ts", "let n: string = 42;\n");
|
|
45
|
+
const res = getDiagnostics(p, {});
|
|
46
|
+
expect(Array.isArray(res)).toBe(true);
|
|
47
|
+
expect((res as any[]).length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns unavailable for an unconfigured non-TS extension", () => {
|
|
51
|
+
const p = write("script.rb", "puts 'hi'\n");
|
|
52
|
+
const res = getDiagnostics(p, {});
|
|
53
|
+
expect(Array.isArray(res)).toBe(false);
|
|
54
|
+
expect((res as any).unavailable).toContain("no diagnostics provider");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns unavailable for a missing file", () => {
|
|
58
|
+
const res = getDiagnostics(path.join(dir, "nope.ts"), {});
|
|
59
|
+
expect((res as any).unavailable).toContain("not found");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("diagnostics · external output parsing", () => {
|
|
64
|
+
it("parses path:line:col: severity message lines", () => {
|
|
65
|
+
const out = "src/a.py:3:5: error: undefined name 'x'\nsrc/a.py:7:1: warning: unused import";
|
|
66
|
+
const diags = parseDiagnosticOutput(out, "ruff");
|
|
67
|
+
expect(diags.length).toBe(2);
|
|
68
|
+
expect(diags[0]).toMatchObject({ line: 3, column: 5, severity: "error" });
|
|
69
|
+
expect(diags[1].severity).toBe("warning");
|
|
70
|
+
expect(diags[0].source).toBe("ruff");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("diagnostics · formatting", () => {
|
|
75
|
+
it("formats a clean result", () => {
|
|
76
|
+
expect(formatDiagnostics("a.ts", [])).toContain("no diagnostics");
|
|
77
|
+
});
|
|
78
|
+
it("formats errors with counts", () => {
|
|
79
|
+
const s = formatDiagnostics("a.ts", [
|
|
80
|
+
{ line: 2, column: 3, severity: "error", message: "boom", code: "TS1", source: "ts" },
|
|
81
|
+
]);
|
|
82
|
+
expect(s).toContain("1 error");
|
|
83
|
+
expect(s).toContain("2:3");
|
|
84
|
+
expect(s).toContain("boom");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { unifiedDiff, countOccurrences } from "../src/core/diff";
|
|
6
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
7
|
+
import { registerBuiltinTools } from "../src/tools/builtin";
|
|
8
|
+
|
|
9
|
+
describe("diff · unifiedDiff", () => {
|
|
10
|
+
it("returns empty for identical input", () => {
|
|
11
|
+
const d = unifiedDiff("a\nb\nc", "a\nb\nc");
|
|
12
|
+
expect(d.text).toBe("");
|
|
13
|
+
expect(d.stat).toEqual({ added: 0, removed: 0 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("shows a single changed line with context and a +/- stat", () => {
|
|
17
|
+
const d = unifiedDiff("a\nb\nc\nd\ne", "a\nb\nX\nd\ne");
|
|
18
|
+
expect(d.stat).toEqual({ added: 1, removed: 1 });
|
|
19
|
+
expect(d.text).toContain("-c");
|
|
20
|
+
expect(d.text).toContain("+X");
|
|
21
|
+
expect(d.text).toContain(" b"); // context line
|
|
22
|
+
expect(d.text).toMatch(/@@ -\d+,\d+ \+\d+,\d+ @@/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("counts added and removed lines for multi-line changes", () => {
|
|
26
|
+
const d = unifiedDiff("x\n1\n2\ny", "x\n1\n2\n3\ny");
|
|
27
|
+
expect(d.stat.added).toBe(1);
|
|
28
|
+
expect(d.stat.removed).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("diff · countOccurrences", () => {
|
|
33
|
+
it("counts non-overlapping occurrences", () => {
|
|
34
|
+
expect(countOccurrences("aaaa", "aa")).toBe(2);
|
|
35
|
+
expect(countOccurrences("abcabc", "abc")).toBe(2);
|
|
36
|
+
expect(countOccurrences("abc", "z")).toBe(0);
|
|
37
|
+
expect(countOccurrences("abc", "")).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("edit_file · Claude Code-style semantics", () => {
|
|
42
|
+
let dir: string;
|
|
43
|
+
let edit: any;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-edit-"));
|
|
46
|
+
const reg = new ToolRegistry();
|
|
47
|
+
registerBuiltinTools(reg);
|
|
48
|
+
edit = reg.get("edit_file")!.handler!;
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
51
|
+
|
|
52
|
+
function write(name: string, content: string): string {
|
|
53
|
+
const p = path.join(dir, name);
|
|
54
|
+
fs.writeFileSync(p, content);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it("replaces a unique match and returns a diff", async () => {
|
|
59
|
+
const p = write("a.txt", "line1\nfoo\nline3\n");
|
|
60
|
+
const out = await edit({ path: p, old_text: "foo", new_text: "bar" });
|
|
61
|
+
expect(out).toContain("Successfully edited");
|
|
62
|
+
expect(out).toContain("-foo");
|
|
63
|
+
expect(out).toContain("+bar");
|
|
64
|
+
expect(fs.readFileSync(p, "utf8")).toBe("line1\nbar\nline3\n");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses an ambiguous edit when old_text is not unique", async () => {
|
|
68
|
+
const p = write("b.txt", "x\nx\n");
|
|
69
|
+
const out = await edit({ path: p, old_text: "x", new_text: "y" });
|
|
70
|
+
expect(out).toContain("appears 2 times");
|
|
71
|
+
expect(out).toContain("replace_all");
|
|
72
|
+
// file unchanged
|
|
73
|
+
expect(fs.readFileSync(p, "utf8")).toBe("x\nx\n");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("replace_all changes every occurrence", async () => {
|
|
77
|
+
const p = write("c.txt", "x\nx\nx\n");
|
|
78
|
+
const out = await edit({ path: p, old_text: "x", new_text: "y", replace_all: true });
|
|
79
|
+
expect(out).toContain("3 occurrences");
|
|
80
|
+
expect(fs.readFileSync(p, "utf8")).toBe("y\ny\ny\n");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("errors when old_text is missing from the file", async () => {
|
|
84
|
+
const p = write("d.txt", "hello\n");
|
|
85
|
+
const out = await edit({ path: p, old_text: "nope", new_text: "x" });
|
|
86
|
+
expect(out).toContain("not found");
|
|
87
|
+
expect(fs.readFileSync(p, "utf8")).toBe("hello\n");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects a no-op edit (old_text === new_text)", async () => {
|
|
91
|
+
const p = write("e.txt", "same\n");
|
|
92
|
+
const out = await edit({ path: p, old_text: "same", new_text: "same" });
|
|
93
|
+
expect(out).toContain("identical");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("treats $-patterns in new_text literally (no String.replace interpretation)", async () => {
|
|
97
|
+
const p = write("f.txt", "value = OLD\n");
|
|
98
|
+
const out = await edit({ path: p, old_text: "OLD", new_text: "$1$&dollar" });
|
|
99
|
+
expect(out).toContain("Successfully edited");
|
|
100
|
+
expect(fs.readFileSync(p, "utf8")).toBe("value = $1$&dollar\n");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { gitInfo, buildEnvBlock } from "../src/core/envcontext";
|
|
6
|
+
|
|
7
|
+
describe("envcontext · gitInfo", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-git-")); });
|
|
10
|
+
afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} });
|
|
11
|
+
|
|
12
|
+
it("reads the branch from a .git directory HEAD", () => {
|
|
13
|
+
fs.mkdirSync(path.join(dir, ".git"));
|
|
14
|
+
fs.writeFileSync(path.join(dir, ".git", "HEAD"), "ref: refs/heads/feature-x\n");
|
|
15
|
+
const info = gitInfo(dir);
|
|
16
|
+
expect(info.repo).toBe(true);
|
|
17
|
+
expect(info.branch).toBe("feature-x");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("falls back to a short sha for a detached HEAD", () => {
|
|
21
|
+
fs.mkdirSync(path.join(dir, ".git"));
|
|
22
|
+
fs.writeFileSync(path.join(dir, ".git", "HEAD"), "0123456789abcdef\n");
|
|
23
|
+
expect(gitInfo(dir).branch).toBe("01234567");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("resolves a worktree .git file pointing at the real gitdir", () => {
|
|
27
|
+
const real = path.join(dir, "realgit");
|
|
28
|
+
fs.mkdirSync(real);
|
|
29
|
+
fs.writeFileSync(path.join(real, "HEAD"), "ref: refs/heads/wt-branch\n");
|
|
30
|
+
fs.writeFileSync(path.join(dir, ".git"), `gitdir: ${real}\n`);
|
|
31
|
+
const info = gitInfo(dir);
|
|
32
|
+
expect(info.repo).toBe(true);
|
|
33
|
+
expect(info.branch).toBe("wt-branch");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("reports no repo when there is no .git up the tree", () => {
|
|
37
|
+
const nested = path.join(dir, "a", "b");
|
|
38
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
39
|
+
expect(gitInfo(nested).repo).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("envcontext · buildEnvBlock", () => {
|
|
44
|
+
it("includes cwd, platform, node, git branch and an injectable date (zh)", () => {
|
|
45
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-env-"));
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(path.join(dir, ".git"));
|
|
48
|
+
fs.writeFileSync(path.join(dir, ".git", "HEAD"), "ref: refs/heads/main\n");
|
|
49
|
+
const block = buildEnvBlock({ cwd: dir, lang: "zh", now: new Date("2026-06-14T08:00:00Z") });
|
|
50
|
+
expect(block).toContain("运行环境");
|
|
51
|
+
expect(block).toContain(dir);
|
|
52
|
+
expect(block).toContain(process.version);
|
|
53
|
+
expect(block).toContain(process.platform);
|
|
54
|
+
expect(block).toContain("main");
|
|
55
|
+
expect(block).toContain("2026-06-14");
|
|
56
|
+
} finally {
|
|
57
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders an English block", () => {
|
|
62
|
+
const block = buildEnvBlock({ cwd: os.tmpdir(), lang: "en", now: new Date("2026-01-02T00:00:00Z") });
|
|
63
|
+
expect(block).toContain("## Environment");
|
|
64
|
+
expect(block).toContain("Working directory");
|
|
65
|
+
expect(block).toContain("2026-01-02");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { ToolRegistry } from "../src/core/tool";
|
|
3
|
+
import { PluginLoader, type PluginContext } from "../src/plugins/loader";
|
|
4
|
+
|
|
5
|
+
describe("PluginLoader · hook lifecycle", () => {
|
|
6
|
+
let reg: ToolRegistry;
|
|
7
|
+
let loader: PluginLoader;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
reg = new ToolRegistry();
|
|
10
|
+
loader = new PluginLoader(reg, { foo: 1 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("activate-style plugin registers a scoped tool and an init hook", async () => {
|
|
14
|
+
let initFired = false;
|
|
15
|
+
loader.activatePlugin("p1", {
|
|
16
|
+
activate(ctx: PluginContext) {
|
|
17
|
+
expect(ctx.config).toEqual({ foo: 1 });
|
|
18
|
+
ctx.registerTool({ name: "p1_tool", description: "t", handler: async () => "ok" });
|
|
19
|
+
ctx.on("init", () => { initFired = true; });
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(reg.has("p1_tool")).toBe(true);
|
|
23
|
+
expect(loader.list()).toContain("p1");
|
|
24
|
+
expect(loader.hookCount("init")).toBe(1);
|
|
25
|
+
|
|
26
|
+
await loader.emit("init");
|
|
27
|
+
expect(initFired).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("unload removes the plugin's tools and hook handlers", async () => {
|
|
31
|
+
let fired = 0;
|
|
32
|
+
loader.activatePlugin("p", {
|
|
33
|
+
activate(ctx: PluginContext) {
|
|
34
|
+
ctx.registerTool({ name: "tmp_tool", description: "t", handler: async () => "ok" });
|
|
35
|
+
ctx.on("init", () => { fired++; });
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(reg.has("tmp_tool")).toBe(true);
|
|
39
|
+
|
|
40
|
+
expect(loader.unload("p")).toBe(true);
|
|
41
|
+
expect(reg.has("tmp_tool")).toBe(false);
|
|
42
|
+
expect(loader.hookCount("init")).toBe(0);
|
|
43
|
+
expect(loader.list()).not.toContain("p");
|
|
44
|
+
|
|
45
|
+
await loader.emit("init");
|
|
46
|
+
expect(fired).toBe(0); // handler gone
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("fires hook handlers in registration order across plugins", async () => {
|
|
50
|
+
const order: string[] = [];
|
|
51
|
+
loader.activatePlugin("a", { activate: (c) => c.on("init", () => { order.push("a"); }) });
|
|
52
|
+
loader.activatePlugin("b", { activate: (c) => c.on("init", () => { order.push("b"); }) });
|
|
53
|
+
await loader.emit("init");
|
|
54
|
+
expect(order).toEqual(["a", "b"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reactivating a name unloads the previous instance (no duplicate tools)", () => {
|
|
58
|
+
loader.activatePlugin("dup", { activate: (c) => c.registerTool({ name: "x", description: "v1", handler: async () => "1" }) });
|
|
59
|
+
loader.activatePlugin("dup", { activate: (c) => c.registerTool({ name: "x", description: "v2", handler: async () => "2" }) });
|
|
60
|
+
expect(loader.list().filter((n) => n === "dup")).toHaveLength(1);
|
|
61
|
+
expect(reg.get("x")?.description).toBe("v2");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("supports legacy register(registry) and tracks its tools for unload", () => {
|
|
65
|
+
loader.activatePlugin("legacy", {
|
|
66
|
+
register(r) { r.register({ name: "legacy_tool", description: "t", handler: async () => "ok" }); },
|
|
67
|
+
});
|
|
68
|
+
expect(reg.has("legacy_tool")).toBe(true);
|
|
69
|
+
loader.unload("legacy");
|
|
70
|
+
expect(reg.has("legacy_tool")).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("isolates a throwing hook handler from the rest", async () => {
|
|
74
|
+
const ran: string[] = [];
|
|
75
|
+
loader.activatePlugin("a", { activate: (c) => c.on("evt", () => { throw new Error("boom"); }) });
|
|
76
|
+
loader.activatePlugin("b", { activate: (c) => c.on("evt", () => { ran.push("b"); }) });
|
|
77
|
+
await loader.emit("evt");
|
|
78
|
+
expect(ran).toEqual(["b"]); // b still ran despite a throwing
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("unload returns false for an unknown plugin", () => {
|
|
82
|
+
expect(loader.unload("nope")).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DangerLevel,
|
|
4
|
+
decideApproval,
|
|
5
|
+
isEditTool,
|
|
6
|
+
SecurityContext,
|
|
7
|
+
} from "../src/core/security";
|
|
8
|
+
|
|
9
|
+
describe("security · decideApproval matrix", () => {
|
|
10
|
+
it("SAFE is always allowed in every mode", () => {
|
|
11
|
+
for (const mode of ["auto", "interactive", "strict", "acceptEdits", "bypass"] as const) {
|
|
12
|
+
expect(decideApproval(DangerLevel.SAFE, mode, "read_file")).toBe("allow");
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("strict denies every non-SAFE tool", () => {
|
|
17
|
+
expect(decideApproval(DangerLevel.LOW, "strict", "write_file")).toBe("deny");
|
|
18
|
+
expect(decideApproval(DangerLevel.HIGH, "strict", "run_bash")).toBe("deny");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("bypass allows everything (red-line is gated elsewhere)", () => {
|
|
22
|
+
expect(decideApproval(DangerLevel.CRITICAL, "bypass", "run_bash")).toBe("allow");
|
|
23
|
+
expect(decideApproval(DangerLevel.HIGH, "bypass", "deploy")).toBe("allow");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("interactive asks for every non-SAFE tool", () => {
|
|
27
|
+
expect(decideApproval(DangerLevel.LOW, "interactive", "write_file")).toBe("ask");
|
|
28
|
+
expect(decideApproval(DangerLevel.HIGH, "interactive", "run_bash")).toBe("ask");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("auto allows LOW, asks MEDIUM/HIGH, denies CRITICAL (unchanged)", () => {
|
|
32
|
+
expect(decideApproval(DangerLevel.LOW, "auto", "write_file")).toBe("allow");
|
|
33
|
+
expect(decideApproval(DangerLevel.MEDIUM, "auto", "git_push")).toBe("ask");
|
|
34
|
+
expect(decideApproval(DangerLevel.HIGH, "auto", "run_bash")).toBe("ask");
|
|
35
|
+
expect(decideApproval(DangerLevel.CRITICAL, "auto", "run_bash")).toBe("deny");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("acceptEdits waves through edit tools but asks for other risky tools", () => {
|
|
39
|
+
expect(decideApproval(DangerLevel.LOW, "acceptEdits", "write_file")).toBe("allow");
|
|
40
|
+
expect(decideApproval(DangerLevel.MEDIUM, "acceptEdits", "delete_file")).toBe("allow"); // edit tool
|
|
41
|
+
expect(decideApproval(DangerLevel.HIGH, "acceptEdits", "run_bash")).toBe("ask"); // not an edit
|
|
42
|
+
expect(decideApproval(DangerLevel.CRITICAL, "acceptEdits", "delete_file")).toBe("deny");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("security · isEditTool", () => {
|
|
47
|
+
it("recognizes filesystem-mutating tools", () => {
|
|
48
|
+
expect(isEditTool("write_file")).toBe(true);
|
|
49
|
+
expect(isEditTool("edit_file")).toBe(true);
|
|
50
|
+
expect(isEditTool("delete_file")).toBe(true);
|
|
51
|
+
expect(isEditTool("move_file")).toBe(true);
|
|
52
|
+
expect(isEditTool("read_file")).toBe(false);
|
|
53
|
+
expect(isEditTool("run_bash")).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("security · checkApproval integration", () => {
|
|
58
|
+
it("blocks red-line shell commands regardless of mode", async () => {
|
|
59
|
+
const sec = new SecurityContext({ mode: "bypass" });
|
|
60
|
+
const [ok, reason] = await sec.checkApproval("run_bash", { command: "rm -rf /" }, "fog");
|
|
61
|
+
expect(ok).toBe(false);
|
|
62
|
+
expect(reason.toLowerCase()).toContain("red-line");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("write_file: auto allows, strict denies, acceptEdits allows", async () => {
|
|
66
|
+
const args = { path: "a.txt", content: "x" };
|
|
67
|
+
expect((await new SecurityContext({ mode: "auto" }).checkApproval("write_file", args, "rain"))[0]).toBe(true);
|
|
68
|
+
expect((await new SecurityContext({ mode: "strict" }).checkApproval("write_file", args, "rain"))[0]).toBe(false);
|
|
69
|
+
expect((await new SecurityContext({ mode: "acceptEdits" }).checkApproval("write_file", args, "rain"))[0]).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("ask defers to the approval callback", async () => {
|
|
73
|
+
const sec = new SecurityContext({ mode: "interactive" });
|
|
74
|
+
let asked = false;
|
|
75
|
+
sec.setApprovalCallback(async () => { asked = true; return false; });
|
|
76
|
+
const [ok] = await sec.checkApproval("write_file", { path: "a", content: "b" }, "rain");
|
|
77
|
+
expect(asked).toBe(true);
|
|
78
|
+
expect(ok).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("setMode switches behavior at runtime", () => {
|
|
82
|
+
const sec = new SecurityContext({ mode: "auto" });
|
|
83
|
+
expect(sec.approvalMode).toBe("auto");
|
|
84
|
+
sec.setMode("bypass");
|
|
85
|
+
expect(sec.approvalMode).toBe("bypass");
|
|
86
|
+
});
|
|
87
|
+
});
|