@uuaid/agent-commons 0.0.0-stage → 0.2.0-alpha.2
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/CONFORMANCE.md +26 -0
- package/LICENSE +201 -0
- package/NOTICE +25 -0
- package/PROVENANCE.json +29 -0
- package/README.md +316 -2
- package/bin/agent-commons.mjs +71 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -4
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +79 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +39 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +594 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +353 -0
- package/src/index.d.ts +109 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +91 -0
- package/src/runtime.mjs +346 -0
- package/src/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +65 -0
- package/src/vendor/pillar/blob/blob.mjs +510 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/identity/jcs.mjs +35 -0
- package/src/vendor/pillar/identity/keychain.mjs +192 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +197 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +127 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
- package/test/channels.test.mjs +268 -0
- package/test/cohort.test.mjs +36 -0
- package/test/collaboration.test.mjs +459 -0
- package/test/continuity.test.mjs +229 -0
- package/test/custody.negative.test.mjs +165 -0
- package/test/derivation-parity.test.mjs +19 -0
- package/test/fixtures/derivation-vectors.json +26 -0
- package/test/fixtures/ed25519-small-order.vectors.json +15 -0
- package/test/governance-expected-red.expected-red.mjs +241 -0
- package/test/governance-scope.test.mjs +142 -0
- package/test/governance-t1-t10.test.mjs +477 -0
- package/test/governance.test.mjs +514 -0
- package/test/hold-admission.test.mjs +1185 -0
- package/test/keychain-strict-ed25519.test.mjs +240 -0
- package/test/offline.test.mjs +152 -0
- package/test/runtime-channels.test.mjs +1140 -0
- package/test/runtime.test.mjs +201 -0
- package/test/security-runtime.test.mjs +104 -0
- package/test/strict-ed25519-consumers.test.mjs +234 -0
- package/test/transport.test.mjs +353 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
CollaborationScheduler, createCollaboration, createRoster, rosterFromModels, createContinuity, createMemoryPersistence, createFilePersistence, memoryFromContinuity,
|
|
8
|
+
normalizePlan, readyTasks, validateProposal, parseModelOutput, DEFAULT_ROSTER, ROSTER_SIZE, CollaborationError,
|
|
9
|
+
validateUnifiedDiff, validatePatchPath, normalizeAllowedPaths, chargeUsage, hmacIntegrity, validateRunState, NOOP_MEMORY,
|
|
10
|
+
} from "../src/collaboration.mjs";
|
|
11
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
/** Deterministic host verifier double (stands in for a sandboxed host check). */
|
|
15
|
+
const HOST = Object.freeze({ verifier: { async verify() { return { passed: true, findings: [] }; } } });
|
|
16
|
+
|
|
17
|
+
const NEEDS = { objective: "Add greeting", acceptanceCriteria: "greet() returns hi", allowedPaths: ["src", "test"] };
|
|
18
|
+
const PLAN = { tasks: [
|
|
19
|
+
{ id: "core", title: "Implement greet", description: "src/greet.mjs" },
|
|
20
|
+
{ id: "tests", title: "Test greet", dependsOn: ["core"] },
|
|
21
|
+
] };
|
|
22
|
+
const patchFor = taskId => ({ summary: `patch ${taskId}`, patches: [{ path: `src/${taskId}.mjs`, op: "create", content: `export const ${taskId} = 1;\n` }] });
|
|
23
|
+
|
|
24
|
+
/** Scripted, provider-neutral fake adapter. `script[phase](request)` returns output; default is happy path. */
|
|
25
|
+
function fakeModel(script = {}) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
let active = 0, maxActive = 0;
|
|
28
|
+
const model = {
|
|
29
|
+
calls, get maxActive() { return maxActive; },
|
|
30
|
+
async complete(request) {
|
|
31
|
+
calls.push({ phase: request.phase, slot: request.slot.id, taskId: request.input?.task?.id, key: request.idempotencyKey, request });
|
|
32
|
+
active++; maxActive = Math.max(maxActive, active);
|
|
33
|
+
try {
|
|
34
|
+
if (script.delay) await new Promise(r => setTimeout(r, script.delay));
|
|
35
|
+
const handler = script[request.phase];
|
|
36
|
+
const out = handler ? await handler(request, calls) : defaults[request.phase](request);
|
|
37
|
+
if (out && out.__raw) return out.__raw;
|
|
38
|
+
return { content: JSON.stringify(out), usage: { inputTokens: 10, outputTokens: 5 } };
|
|
39
|
+
} finally { active--; }
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return model;
|
|
43
|
+
}
|
|
44
|
+
const defaults = {
|
|
45
|
+
needs: () => ({ questions: [{ id: "styleGuide", question: "Which style guide?", required: false }] }),
|
|
46
|
+
plan: () => PLAN,
|
|
47
|
+
build: r => patchFor(r.input.task.id),
|
|
48
|
+
test: () => ({ passed: true, findings: [] }),
|
|
49
|
+
review: () => ({ verdict: "approve", comments: [] }),
|
|
50
|
+
};
|
|
51
|
+
const make = (opts = {}) => new CollaborationScheduler({ ...HOST, model: fakeModel(), ...opts });
|
|
52
|
+
|
|
53
|
+
test("default roster has seven configurable slots covering every role", () => {
|
|
54
|
+
assert.equal(DEFAULT_ROSTER.length, ROSTER_SIZE);
|
|
55
|
+
const custom = createRoster({ "builder-c": { role: "reviewer", model: "m-7" } });
|
|
56
|
+
assert.equal(custom.find(s => s.id === "builder-c").role, "reviewer");
|
|
57
|
+
assert.throws(() => createRoster(DEFAULT_ROSTER.slice(0, 6)), /exactly 7/);
|
|
58
|
+
assert.throws(() => createRoster(DEFAULT_ROSTER.map(s => ({ ...s, role: s.role === "tester" ? "builder" : s.role }))), /missing a tester/);
|
|
59
|
+
assert.throws(() => createRoster({ nope: {} }), /Unknown roster slot/);
|
|
60
|
+
const bound = rosterFromModels(["m1", "m2", "m3", "m4", "m5", "m6", "m7"]);
|
|
61
|
+
assert.deepEqual(bound.map(s => s.model), ["m1", "m2", "m3", "m4", "m5", "m6", "m7"]);
|
|
62
|
+
assert.throws(() => rosterFromModels(["m1"]), /Expected 7/);
|
|
63
|
+
assert.throws(() => new CollaborationScheduler({}), /model adapter/);
|
|
64
|
+
assert.throws(() => make({ limits: { maxRounds: 0 } }), /positive integer/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("asks operational needs first and pauses until required needs are answered", async () => {
|
|
68
|
+
const model = fakeModel();
|
|
69
|
+
const persistence = createMemoryPersistence();
|
|
70
|
+
const s = new CollaborationScheduler({ ...HOST, model, persistence });
|
|
71
|
+
const first = await s.run({ runId: "r1", goal: "Add greeting" });
|
|
72
|
+
assert.equal(first.status, "awaiting-needs");
|
|
73
|
+
assert.deepEqual(first.questions.map(q => q.id).sort(), ["acceptanceCriteria", "allowedPaths", "objective"]);
|
|
74
|
+
assert.deepEqual(model.calls.map(c => c.phase), ["needs"]);
|
|
75
|
+
await s.provideNeeds("r1", NEEDS);
|
|
76
|
+
const done = await s.run({ runId: "r1" });
|
|
77
|
+
assert.equal(done.status, "completed");
|
|
78
|
+
assert.equal(model.calls.filter(c => c.phase === "needs").length, 1, "needs questions are not re-asked");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("needs provider is consulted before planning", async () => {
|
|
82
|
+
const order = [];
|
|
83
|
+
const model = fakeModel({ plan: () => { order.push("plan"); return PLAN; } });
|
|
84
|
+
const s = new CollaborationScheduler({ ...HOST, model, needs: { async ask({ missing }) { order.push(`ask:${missing.length}`); return NEEDS; } } });
|
|
85
|
+
const result = await s.run({ runId: "r2", goal: "Add greeting" });
|
|
86
|
+
assert.equal(result.status, "completed");
|
|
87
|
+
assert.deepEqual(order, ["ask:3", "plan"]);
|
|
88
|
+
assert.equal(model.calls[0].phase, "needs");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("plan/build/test/peer-review cycle respects dependencies and yields patch artifacts", async () => {
|
|
92
|
+
const model = fakeModel();
|
|
93
|
+
const sink = [];
|
|
94
|
+
const s = new CollaborationScheduler({ ...HOST, model, artifacts: { async put(a) { sink.push(a) } } });
|
|
95
|
+
const result = await s.run({ runId: "r3", goal: "Add greeting", needs: NEEDS });
|
|
96
|
+
assert.equal(result.status, "completed");
|
|
97
|
+
assert.equal(result.rounds, 2, "dependent task waits for its dependency");
|
|
98
|
+
const builds = model.calls.filter(c => c.phase === "build");
|
|
99
|
+
assert.deepEqual(builds.map(c => c.taskId), ["core", "tests"]);
|
|
100
|
+
assert.deepEqual(builds[1].request.input.dependencies[0].approved[0].paths, ["src/core.mjs"]);
|
|
101
|
+
const reviews = model.calls.filter(c => c.phase === "review" && c.taskId === "core");
|
|
102
|
+
assert.equal(reviews.length, 2, "reviewer plus a peer builder");
|
|
103
|
+
const author = result.tasks.find(t => t.id === "core").assignee;
|
|
104
|
+
assert.ok(!reviews.some(r => r.slot === author), "author never reviews own proposal");
|
|
105
|
+
assert.equal(result.artifacts.length, 2);
|
|
106
|
+
assert.equal(result.artifacts[0].kind, "patch-proposal");
|
|
107
|
+
assert.equal(sink.length, 2);
|
|
108
|
+
for (const c of model.calls) assert.ok(c.request.maxTokens > 0 && c.request.idempotencyKey && c.request.signal);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("changes requested trigger rework with feedback; identical resubmission is deduplicated", async () => {
|
|
112
|
+
let reviewCount = 0;
|
|
113
|
+
const model = fakeModel({
|
|
114
|
+
plan: () => ({ tasks: [{ id: "core", title: "Implement greet" }] }),
|
|
115
|
+
review: () => (++reviewCount <= 2 ? { verdict: "request-changes", comments: ["add docs"] } : { verdict: "approve" }),
|
|
116
|
+
build: r => (r.input.feedback.length >= 2 ? { summary: "v2", patches: [{ path: "src/core.mjs", op: "create", content: "// docs\n" }] } : patchFor("core")),
|
|
117
|
+
});
|
|
118
|
+
const s = new CollaborationScheduler({ ...HOST, model, limits: { maxAttemptsPerTask: 5 } });
|
|
119
|
+
const result = await s.run({ runId: "r4", goal: "g", needs: NEEDS });
|
|
120
|
+
assert.equal(result.status, "completed");
|
|
121
|
+
assert.equal(result.rounds, 3);
|
|
122
|
+
assert.equal(result.tasks[0].attempts, 2);
|
|
123
|
+
const state = await s.persistence.load("r4");
|
|
124
|
+
assert.deepEqual(state.tasks.core.feedback.map(f => f.reason), ["changes-requested", "duplicate-proposal"]);
|
|
125
|
+
assert.equal(model.calls.filter(c => c.phase === "review").length, 4, "duplicate proposal spends no review tokens");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("proposals with commands or unsafe paths are rejected, never executed", async () => {
|
|
129
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/a", op: "create", content: "" }], command: "rm -rf /" }), e => e.code === "forbidden-execution");
|
|
130
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/a", op: "create", content: "", shell: "x" }] }), /executable field/);
|
|
131
|
+
for (const path of ["../etc/passwd", "/etc/passwd", "src/../../x", "C:/x", ".git/config", "a\\b"]) assert.throws(() => validateProposal({ patches: [{ path, op: "create", content: "" }] }), /Unsafe/);
|
|
132
|
+
assert.throws(() => validateProposal({ patches: [{ path: "lib/x", op: "create", content: "" }] }, { allowedPaths: ["src"] }), /outside allowed/);
|
|
133
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/x", op: "modify" }] }, { allowedPaths: ["src"] }), /unified diff/);
|
|
134
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/x", op: "create", content: "x".repeat(20) }] }, { allowedPaths: ["src"], maxPatchBytes: 10 }), /limit/);
|
|
135
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/x", op: "create", content: "" }] }), /No allowed paths/);
|
|
136
|
+
const model = fakeModel({ plan: () => ({ tasks: [{ id: "a", title: "A" }] }), build: () => ({ patches: [{ path: "src/a", op: "create", content: "" }], run: "npm test" }) });
|
|
137
|
+
const result = await new CollaborationScheduler({ ...HOST, model, limits: { maxAttemptsPerTask: 1 } }).run({ runId: "r5", goal: "g", needs: NEEDS });
|
|
138
|
+
assert.equal(result.status, "failed");
|
|
139
|
+
assert.equal(model.calls.filter(c => c.phase === "test").length, 0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("round, token, and time limits stop runs and are resumable", async () => {
|
|
143
|
+
const stubborn = { review: () => ({ verdict: "request-changes", comments: ["no"] }), build: r => ({ patches: [{ path: `src/${r.input.feedback.length}.mjs`, op: "create", content: "x" }] }) };
|
|
144
|
+
const r1 = await new CollaborationScheduler({ ...HOST, model: fakeModel(stubborn), limits: { maxRounds: 2, maxAttemptsPerTask: 10 } }).run({ runId: "lim", goal: "g", needs: NEEDS });
|
|
145
|
+
assert.equal(r1.status, "round-limit");
|
|
146
|
+
assert.equal(r1.rounds, 2);
|
|
147
|
+
|
|
148
|
+
const r2 = await new CollaborationScheduler({ ...HOST, model: fakeModel(), limits: { maxTokens: 40 } }).run({ runId: "tok", goal: "g", needs: NEEDS });
|
|
149
|
+
assert.equal(r2.status, "token-limit");
|
|
150
|
+
assert.ok(r2.tokensUsed <= 40, "prompt reservation stops before overspending");
|
|
151
|
+
|
|
152
|
+
let now = 0;
|
|
153
|
+
const model = fakeModel({ build: r => { now += 1000; return patchFor(r.input.task.id); } });
|
|
154
|
+
const persistence = createMemoryPersistence();
|
|
155
|
+
const r3 = await new CollaborationScheduler({ ...HOST, model, persistence, clock: { now: () => now }, limits: { maxDurationMs: 500 } }).run({ runId: "time", goal: "g", needs: NEEDS });
|
|
156
|
+
assert.equal(r3.status, "time-limit");
|
|
157
|
+
const resumed = await new CollaborationScheduler({ ...HOST, model, persistence, clock: { now: () => now } }).run({ runId: "time" });
|
|
158
|
+
assert.equal(resumed.status, "completed");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("concurrency limit bounds parallel model calls", async () => {
|
|
162
|
+
const model = fakeModel({ delay: 5, plan: () => ({ tasks: ["a", "b", "c", "d", "e"].map(id => ({ id, title: id })) }) });
|
|
163
|
+
const result = await new CollaborationScheduler({ ...HOST, model, limits: { maxConcurrency: 2 } }).run({ runId: "cc", goal: "g", needs: NEEDS });
|
|
164
|
+
assert.equal(result.status, "completed");
|
|
165
|
+
assert.equal(result.rounds, 1);
|
|
166
|
+
assert.equal(model.maxActive, 2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("cancellation aborts in-flight work and persists cancelled status", async () => {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const model = fakeModel({ build: r => new Promise(resolve => { setTimeout(() => resolve(patchFor(r.input.task.id)), 1000); setTimeout(() => controller.abort("operator stop"), 5); }) });
|
|
172
|
+
const persistence = createMemoryPersistence();
|
|
173
|
+
const s = new CollaborationScheduler({ ...HOST, model, persistence, signal: controller.signal });
|
|
174
|
+
const result = await s.run({ runId: "cx", goal: "g", needs: NEEDS });
|
|
175
|
+
assert.equal(result.status, "cancelled");
|
|
176
|
+
assert.match(result.stopReason, /operator stop/);
|
|
177
|
+
assert.equal((await persistence.load("cx")).status, "cancelled");
|
|
178
|
+
const again = await new CollaborationScheduler({ ...HOST, model, persistence }).run({ runId: "cx" });
|
|
179
|
+
assert.equal(again.status, "cancelled", "terminal runs are not restarted");
|
|
180
|
+
const s2 = make();
|
|
181
|
+
s2.cancel("pre");
|
|
182
|
+
assert.equal((await s2.run({ runId: "pre", goal: "g", needs: NEEDS })).status, "cancelled");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("idempotent resume after crash reuses cached calls; completed and concurrent runs dedupe", async () => {
|
|
186
|
+
let crash = true;
|
|
187
|
+
const model = fakeModel({ test: () => { if (crash) { crash = false; throw new Error("adapter down"); } return { passed: true }; } });
|
|
188
|
+
const dir = mkdtempSync(join(tmpdir(), "collab-"));
|
|
189
|
+
try {
|
|
190
|
+
const persistence = createFilePersistence(dir);
|
|
191
|
+
await assert.rejects(new CollaborationScheduler({ ...HOST, model, persistence }).run({ runId: "idem", goal: "g", needs: NEEDS }), /adapter down/);
|
|
192
|
+
assert.equal((await persistence.load("idem")).status, "interrupted");
|
|
193
|
+
const before = model.calls.length;
|
|
194
|
+
const resumed = await new CollaborationScheduler({ ...HOST, model, persistence }).run({ runId: "idem" });
|
|
195
|
+
assert.equal(resumed.status, "completed");
|
|
196
|
+
const after = model.calls.slice(before);
|
|
197
|
+
assert.ok(!after.some(c => c.phase === "needs" || c.phase === "plan"), "earlier phases are not re-called");
|
|
198
|
+
assert.ok(!after.some(c => c.phase === "build" && c.taskId === "core"), "cached build is reused");
|
|
199
|
+
const count = model.calls.length;
|
|
200
|
+
const replay = await new CollaborationScheduler({ ...HOST, model, persistence }).run({ runId: "idem" });
|
|
201
|
+
assert.deepEqual(replay, resumed);
|
|
202
|
+
assert.equal(model.calls.length, count);
|
|
203
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
204
|
+
const s = make();
|
|
205
|
+
const [a, b] = [s.run({ runId: "same", goal: "g", needs: NEEDS }), s.run({ runId: "same", goal: "g", needs: NEEDS })];
|
|
206
|
+
assert.equal(a, b);
|
|
207
|
+
assert.equal((await a).status, "completed");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("dependency tracking: dedup, unknown deps, cycles, and blocked dependents", async () => {
|
|
211
|
+
const tasks = normalizePlan([{ id: "a", title: "Do A" }, { id: "a2", title: "do a", dependsOn: [] }, { id: "b", title: "B", dependsOn: ["a2"] }]);
|
|
212
|
+
assert.deepEqual(Object.keys(tasks), ["a", "b"]);
|
|
213
|
+
assert.deepEqual(tasks.b.dependsOn, ["a"]);
|
|
214
|
+
assert.deepEqual(readyTasks(tasks).map(t => t.id), ["a"]);
|
|
215
|
+
assert.throws(() => normalizePlan([{ id: "a", title: "A", dependsOn: ["zz"] }]), /unknown task/);
|
|
216
|
+
assert.throws(() => normalizePlan([{ id: "a", title: "A", dependsOn: ["b"] }, { id: "b", title: "B", dependsOn: ["a"] }]), /cycle/);
|
|
217
|
+
assert.throws(() => normalizePlan([{ id: "a", title: "A", dependsOn: ["a"] }]), /itself/);
|
|
218
|
+
const cyc = await new CollaborationScheduler({ ...HOST, model: fakeModel({ plan: () => ({ tasks: [{ id: "a", title: "A", dependsOn: ["b"] }, { id: "b", title: "B", dependsOn: ["a"] }] }) }) }).run({ runId: "cyc", goal: "g", needs: NEEDS });
|
|
219
|
+
assert.equal(cyc.status, "failed");
|
|
220
|
+
assert.match(cyc.stopReason, /cycle/);
|
|
221
|
+
const blocked = await new CollaborationScheduler({ ...HOST, model: fakeModel({ test: r => ({ passed: r.input.task.id !== "core", findings: ["fails"] }) }), limits: { maxAttemptsPerTask: 2 } }).run({ runId: "blk", goal: "g", needs: NEEDS });
|
|
222
|
+
assert.equal(blocked.status, "failed");
|
|
223
|
+
assert.deepEqual(blocked.tasks.map(t => [t.id, t.status]), [["core", "failed"], ["tests", "blocked"]]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("memory hooks are recalled before work and record each methodical step", async () => {
|
|
227
|
+
const recalls = [], records = [];
|
|
228
|
+
const memory = { async recall(q) { recalls.push(q.phase); return [{ note: "prefer small patches" }]; }, async record(e) { records.push(e.kind); } };
|
|
229
|
+
const model = fakeModel();
|
|
230
|
+
const result = await createCollaboration({ ...HOST, model, ...createContinuity({ memory }) }).run({ runId: "mem", goal: "g", needs: NEEDS });
|
|
231
|
+
assert.equal(result.status, "completed");
|
|
232
|
+
assert.deepEqual([...new Set(recalls)], ["needs", "plan", "build"]);
|
|
233
|
+
for (const kind of ["needs", "plan", "proposal", "test", "approval", "outcome"]) assert.ok(records.includes(kind), kind);
|
|
234
|
+
assert.deepEqual(model.calls.find(c => c.phase === "plan").request.input.memory, [{ note: "prefer small patches" }]);
|
|
235
|
+
const failing = { async recall() { throw new Error("memory offline") }, async record() { throw new Error("memory offline") } };
|
|
236
|
+
assert.equal((await make({ memory: failing, memoryFailurePolicy: "best-effort" }).run({ runId: "mem2", goal: "g", needs: NEEDS })).status, "completed", "best-effort must be explicit");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("model output parsing is provider neutral", () => {
|
|
240
|
+
assert.deepEqual(parseModelOutput({ output: { a: 1 } }), { a: 1 });
|
|
241
|
+
assert.deepEqual(parseModelOutput({ content: "```json\n{\"a\":2}\n```" }), { a: 2 });
|
|
242
|
+
assert.deepEqual(parseModelOutput({ text: "Sure: {\"a\":3} done" }), { a: 3 });
|
|
243
|
+
assert.deepEqual(parseModelOutput("{\"a\":4}"), { a: 4 });
|
|
244
|
+
assert.throws(() => parseModelOutput({ content: "nope" }), CollaborationError);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("continuity journals adapt to memory hooks via duck typing", async () => {
|
|
248
|
+
const entries = [];
|
|
249
|
+
const journal = { remember(kind, content) { assert.match(kind, /^[a-z0-9:_-]{1,64}$/); entries.push({ kind, content: structuredClone(content), at: entries.length }); }, recall({ limit }) { return entries.slice(-limit); } };
|
|
250
|
+
const { persistence, memory } = createContinuity({ continuity: journal });
|
|
251
|
+
const model = fakeModel();
|
|
252
|
+
assert.equal((await createCollaboration({ ...HOST, model, persistence, memory }).run({ runId: "j1", goal: "g", needs: NEEDS })).status, "completed");
|
|
253
|
+
assert.ok(entries.some(e => e.kind === "collab:outcome" && e.content.runId === "j1"));
|
|
254
|
+
const recalled = await memoryFromContinuity(journal).recall({ runId: "j2", phase: "needs" });
|
|
255
|
+
assert.ok(recalled.some(e => e.kind === "collab:outcome"), "prior outcomes inform new runs");
|
|
256
|
+
assert.throws(() => memoryFromContinuity({}), /remember/);
|
|
257
|
+
assert.throws(() => createContinuity({ memory: {}, continuity: journal }), /not both/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------- security hardening
|
|
261
|
+
const DIFF = (path, from = path, to = path) => `--- a/${from}\n+++ b/${to}\n@@ -1,2 +1,2 @@\n context\n-old\n+new\n`;
|
|
262
|
+
|
|
263
|
+
test("C1: empty or root allowlists pause for needs; protected paths cannot be overridden by model or needs input", async () => {
|
|
264
|
+
for (const [i, allowedPaths] of [[], "", " , ", ["."], ["./"], ["../x"], ["/abs"], [".env"]].entries()) {
|
|
265
|
+
const model = fakeModel();
|
|
266
|
+
const r = await new CollaborationScheduler({ ...HOST, model }).run({ runId: `ap-${i}`, goal: "g", needs: { ...NEEDS, allowedPaths } });
|
|
267
|
+
assert.equal(r.status, "awaiting-needs", JSON.stringify(allowedPaths));
|
|
268
|
+
assert.ok(r.questions.some(q => q.id === "allowedPaths"));
|
|
269
|
+
assert.ok(!model.calls.some(c => c.phase !== "needs"), "no planning without an explicit allowlist");
|
|
270
|
+
}
|
|
271
|
+
assert.deepEqual(normalizeAllowedPaths("src, test/"), ["src", "test"]);
|
|
272
|
+
// slash-bounded prefixes: "src" does not admit "src-evil"
|
|
273
|
+
assert.throws(() => validatePatchPath("src-evil/a.mjs", ["src"]), /outside allowed/);
|
|
274
|
+
assert.equal(validatePatchPath("src/a.mjs", ["src"]), "src/a.mjs");
|
|
275
|
+
const wide = [".github", "src", "packages", "config", "."].filter(p => p !== ".");
|
|
276
|
+
for (const path of [".github/workflows/ci.yml", "src/.env", "src/.env.local", "config/.npmrc", "src/identity.json", "src/local-secret.key", "packages/agent-commons/package.json",
|
|
277
|
+
"packages/agent-commons/src/trust.mjs", "packages/agent-commons/src/governance.mjs", "src/release-plan.mjs", "src/policy/x.mjs", "src/admission.mjs", "src/budget.mjs", "packages/agent-commons/src/collaboration.mjs"])
|
|
278
|
+
assert.throws(() => validatePatchPath(path, wide), e => e.code === "protected-path", path);
|
|
279
|
+
// host maintenance (constructor only) may unlock the code surface but never secrets
|
|
280
|
+
const maint = { protectedPaths: [], maintenancePaths: ["packages/agent-commons/src/trust.mjs"] };
|
|
281
|
+
assert.equal(validatePatchPath("packages/agent-commons/src/trust.mjs", wide, maint), "packages/agent-commons/src/trust.mjs");
|
|
282
|
+
assert.throws(() => validatePatchPath("packages/agent-commons/src/trust.mjs.bak/x", wide, maint), /protected/);
|
|
283
|
+
assert.throws(() => make({ hostMaintenance: { reason: "rotate", authorizedPaths: ["src/.env"] } }), /Secrets cannot be authorized/);
|
|
284
|
+
assert.throws(() => make({ hostMaintenance: { authorizedPaths: ["src/trust.mjs"] } }), /reason/);
|
|
285
|
+
// host-added protected prefixes are additive
|
|
286
|
+
assert.throws(() => validatePatchPath("src/vendor/x.mjs", ["src"], { protectedPaths: ["src/vendor"] }), /host-protected/);
|
|
287
|
+
|
|
288
|
+
// Scheduler level: needs answers / model output claiming authority are ignored.
|
|
289
|
+
const model = fakeModel({
|
|
290
|
+
plan: () => ({ tasks: [{ id: "ci", title: "Edit CI" }] }),
|
|
291
|
+
build: () => ({ hostMaintenance: { authorizedPaths: [".github"] }, protectedPaths: [], patches: [{ path: ".github/workflows/ci.yml", op: "create", content: "on: push" }] }),
|
|
292
|
+
});
|
|
293
|
+
const r = await new CollaborationScheduler({ ...HOST, model, limits: { maxAttemptsPerTask: 1 } })
|
|
294
|
+
.run({ runId: "prot", goal: "g", needs: { ...NEEDS, allowedPaths: [".github", "src"], protectedPaths: [], hostMaintenance: { reason: "x", authorizedPaths: [".github"] } } });
|
|
295
|
+
assert.equal(r.status, "failed");
|
|
296
|
+
assert.ok(!model.calls.some(c => c.phase === "test" || c.phase === "review"));
|
|
297
|
+
const authorized = await new CollaborationScheduler({ ...HOST, model: fakeModel({ plan: () => ({ tasks: [{ id: "ci", title: "Edit CI" }] }), build: () => ({ patches: [{ path: ".github/workflows/ci.yml", op: "create", content: "on: push" }] }) }), hostMaintenance: { reason: "CI maintenance ticket", authorizedPaths: [".github/workflows"] } })
|
|
298
|
+
.run({ runId: "prot-ok", goal: "g", needs: { ...NEEDS, allowedPaths: [".github"] } });
|
|
299
|
+
assert.equal(authorized.status, "completed");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("C2: model tests are advisory; completion requires an injected host verifier", async () => {
|
|
303
|
+
const advisory = await new CollaborationScheduler({ model: fakeModel() }).run({ runId: "adv", goal: "g", needs: NEEDS });
|
|
304
|
+
assert.equal(advisory.status, "needs-host-verification");
|
|
305
|
+
assert.equal(advisory.verifiedByHost, false);
|
|
306
|
+
assert.equal(advisory.artifacts.length, 2);
|
|
307
|
+
const allowed = await new CollaborationScheduler({ model: fakeModel(), allowAdvisory: true }).run({ runId: "adv2", goal: "g", needs: NEEDS });
|
|
308
|
+
assert.equal(allowed.status, "completed");
|
|
309
|
+
assert.equal(allowed.verifiedByHost, false);
|
|
310
|
+
const verified = [];
|
|
311
|
+
const verifier = { async verify(artifact) { verified.push(artifact.id); return { passed: !artifact.patches.some(p => p.content?.includes("BAD")), findings: ["host check"] }; } };
|
|
312
|
+
let first = true;
|
|
313
|
+
const model = fakeModel({ plan: () => ({ tasks: [{ id: "core", title: "Core" }] }), build: () => { const c = first ? "BAD" : "good"; first = false; return { patches: [{ path: "src/core.mjs", op: "create", content: c }] }; } });
|
|
314
|
+
const host = await new CollaborationScheduler({ model, verifier }).run({ runId: "hv", goal: "g", needs: NEEDS });
|
|
315
|
+
assert.equal(host.status, "completed");
|
|
316
|
+
assert.equal(host.verifiedByHost, true);
|
|
317
|
+
assert.equal(verified.length, 2, "host verifier rejected the first proposal");
|
|
318
|
+
assert.equal(host.rounds, 2);
|
|
319
|
+
assert.throws(() => new CollaborationScheduler({ model, allowAdvisory: "yes" }), /boolean/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("spend accounting rejects negative/invalid usage and never trusts under-reporting", async () => {
|
|
323
|
+
assert.equal(chargeUsage({ content: "x".repeat(400), usage: { totalTokens: -600 } }, 10).tokens, 110);
|
|
324
|
+
assert.deepEqual(chargeUsage({ content: "", usage: { inputTokens: 1.5, outputTokens: NaN } }, 7).invalid, ["inputTokens", "outputTokens"]);
|
|
325
|
+
assert.equal(chargeUsage({ content: "", usage: { totalTokens: Number.POSITIVE_INFINITY } }, 7).tokens, 7);
|
|
326
|
+
assert.equal(chargeUsage({ content: "abcd", usage: { totalTokens: 0 } }, 5).tokens, 6, "under-report floors at local estimate");
|
|
327
|
+
assert.equal(chargeUsage({ content: "", usage: { inputTokens: 900, outputTokens: 100 } }, 5).tokens, 1000);
|
|
328
|
+
const events = [];
|
|
329
|
+
const model = { async complete(r) { return { content: JSON.stringify(defaults[r.phase](r)), usage: { totalTokens: -600, inputTokens: -1 } }; } };
|
|
330
|
+
const r = await new CollaborationScheduler({ ...HOST, model, onEvent: e => events.push(e) }).run({ runId: "neg", goal: "g", needs: NEEDS });
|
|
331
|
+
assert.equal(r.status, "completed");
|
|
332
|
+
assert.ok(r.tokensUsed > 0);
|
|
333
|
+
assert.ok(events.some(e => e.type === "usage-invalid"));
|
|
334
|
+
const capped = await new CollaborationScheduler({ ...HOST, model, limits: { maxTokens: Math.floor(r.tokensUsed / 2) } }).run({ runId: "neg2", goal: "g", needs: NEEDS });
|
|
335
|
+
assert.equal(capped.status, "token-limit", "negative usage cannot extend the budget");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("unified diffs must target exactly the declared path", async () => {
|
|
339
|
+
assert.equal(validateUnifiedDiff("src/a.mjs", DIFF("src/a.mjs")), true);
|
|
340
|
+
assert.equal(validateUnifiedDiff("src/a.mjs", `diff --git a/src/a.mjs b/src/a.mjs\nindex 1234abc..5678def 100644\n${DIFF("src/a.mjs")}`), true);
|
|
341
|
+
assert.equal(validateUnifiedDiff("src/a.mjs", "--- a/src/a.mjs\n+++ b/src/a.mjs\n@@ -1,2 +1,1 @@\n--- not a header\n keep\n"), true, "hunk content that looks like a header is counted, not parsed");
|
|
342
|
+
const reject = [
|
|
343
|
+
DIFF("src/a.mjs", "src/a.mjs", "package.json"), DIFF("src/a.mjs", "package.json", "src/a.mjs"),
|
|
344
|
+
DIFF("src/a.mjs") + DIFF("package.json"), `--- /dev/null\n+++ b/src/a.mjs\n@@ -0,0 +1 @@\n+x\n`,
|
|
345
|
+
`diff --git a/src/a.mjs b/package.json\n${DIFF("src/a.mjs")}`, `diff --git a/src/a.mjs b/src/a.mjs\nrename from src/a.mjs\nrename to package.json\n`,
|
|
346
|
+
`diff --git a/src/a.mjs b/src/a.mjs\nGIT binary patch\n`, `--- a/src/a.mjs\n+++ b/src/a.mjs\n@@ -1,3 +1,3 @@\n context\n-old\n`,
|
|
347
|
+
`--- a/src/a.mjs\n+++ b/src/a.mjs\n`, `junk\n${DIFF("src/a.mjs")}`, `--- a/src/a.mjs\n+++ b/src/a.mjs\n@@ -1 +1 @@\n same\n`,
|
|
348
|
+
`diff --git a/src/a.mjs b/src/a.mjs\nindex 1234abc..5678def 120000\n${DIFF("src/a.mjs")}`,
|
|
349
|
+
];
|
|
350
|
+
for (const diff of reject) assert.throws(() => validateUnifiedDiff("src/a.mjs", diff), /rejected/, diff);
|
|
351
|
+
assert.throws(() => validateProposal({ patches: [{ path: "src/a.mjs", op: "modify", diff: DIFF("src/a.mjs"), content: "x" }] }, { allowedPaths: ["src"] }), /may not also carry/);
|
|
352
|
+
// governance cross-review probe: declared path allowed, header targets package.json
|
|
353
|
+
const model = fakeModel({ plan: () => ({ tasks: [{ id: "a", title: "A" }] }), build: () => ({ patches: [{ path: "src/safe.mjs", op: "modify", diff: DIFF("src/safe.mjs", "src/safe.mjs", "package.json") }] }) });
|
|
354
|
+
const r = await new CollaborationScheduler({ ...HOST, model, limits: { maxAttemptsPerTask: 1 } }).run({ runId: "probe", goal: "g", needs: { ...NEEDS, allowedPaths: ["src"] } });
|
|
355
|
+
assert.equal(r.status, "failed");
|
|
356
|
+
assert.equal(r.artifacts.length, 0);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("configured memory fails closed (halt) with no further model calls; best-effort is explicit", async () => {
|
|
360
|
+
assert.throws(() => make({ memoryFailurePolicy: "ignore" }), /memoryFailurePolicy/);
|
|
361
|
+
for (const [name, memory] of [
|
|
362
|
+
["record throws", { async recall() { return []; }, async record(e) { if (e.kind === "plan") throw new Error("disk full"); } }],
|
|
363
|
+
["recall corrupt", { async recall(q) { return q.phase === "build" ? { not: "an array" } : []; }, async record() {} }],
|
|
364
|
+
]) {
|
|
365
|
+
const model = fakeModel();
|
|
366
|
+
const persistence = createMemoryPersistence();
|
|
367
|
+
const runId = name.replace(/\s+/g, "-");
|
|
368
|
+
const r = await new CollaborationScheduler({ ...HOST, model, memory, persistence }).run({ runId, goal: "g", needs: NEEDS });
|
|
369
|
+
assert.equal(r.status, "memory-failure", name);
|
|
370
|
+
assert.match(r.stopReason, /Memory (record|recall) failed/);
|
|
371
|
+
const phases = model.calls.map(c => c.phase);
|
|
372
|
+
if (name === "record throws") assert.deepEqual(phases, ["needs", "plan"], "no build after plan record failed");
|
|
373
|
+
else assert.ok(!phases.includes("build"), "no build when recall is corrupt");
|
|
374
|
+
assert.equal((await persistence.load(runId)).status, "memory-failure");
|
|
375
|
+
const fixed = await new CollaborationScheduler({ ...HOST, model, persistence, memory: { async recall() { return []; }, async record() {} } }).run({ runId });
|
|
376
|
+
assert.equal(fixed.status, "completed", "resumable after memory is repaired");
|
|
377
|
+
}
|
|
378
|
+
const bestEffort = await make({ memory: { async recall() { return "garbage"; }, async record() { throw new Error("x"); } }, memoryFailurePolicy: "best-effort" }).run({ runId: "be", goal: "g", needs: NEEDS });
|
|
379
|
+
assert.equal(bestEffort.status, "completed");
|
|
380
|
+
assert.equal((await make({ memory: NOOP_MEMORY }).run({ runId: "noop", goal: "g", needs: NEEDS })).status, "completed");
|
|
381
|
+
// continuity journal guards
|
|
382
|
+
const journal = (extra) => ({ remember() {}, recall() { return []; }, ...extra });
|
|
383
|
+
const nonDurable = await make({ memory: memoryFromContinuity(journal({ status: () => ({ durable: false }) })) }).run({ runId: "nd", goal: "g", needs: NEEDS });
|
|
384
|
+
assert.equal(nonDurable.status, "memory-failure");
|
|
385
|
+
assert.match(nonDurable.stopReason, /not durable/);
|
|
386
|
+
const unverified = await make({ memory: memoryFromContinuity(journal({ verify: () => false })) }).run({ runId: "uv", goal: "g", needs: NEEDS });
|
|
387
|
+
assert.match(unverified.stopReason, /verification/);
|
|
388
|
+
const stored = [];
|
|
389
|
+
const big = memoryFromContinuity(journal({ remember: (k, c) => stored.push(c) }), { maxEntryBytes: 200 });
|
|
390
|
+
await big.record({ kind: "needs", runId: "r", round: 0, data: { blob: "x".repeat(1000), ratio: 0.5 } });
|
|
391
|
+
await big.record({ kind: "note", runId: "r", round: 0, data: { ratio: 0.5, missing: undefined } });
|
|
392
|
+
assert.equal(stored[0].data.truncated, true);
|
|
393
|
+
assert.deepEqual(stored[1].data, { ratio: "0.5" }, "canonical JSON: decimals as strings, undefined dropped");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("persistence failures and corrupt state fail closed without model calls", async () => {
|
|
397
|
+
// save failure
|
|
398
|
+
let saves = 0;
|
|
399
|
+
const flaky = { async load() { return null; }, async save() { if (++saves > 2) throw new Error("EIO"); } };
|
|
400
|
+
const model = fakeModel();
|
|
401
|
+
const r = await new CollaborationScheduler({ ...HOST, model, persistence: flaky }).run({ runId: "pf", goal: "g", needs: NEEDS });
|
|
402
|
+
assert.equal(r.status, "persistence-failure");
|
|
403
|
+
assert.equal(r.persisted, false);
|
|
404
|
+
assert.ok(model.calls.length <= 2, `stopped promptly (${model.calls.length} calls)`);
|
|
405
|
+
// load failure and corrupt state
|
|
406
|
+
const m2 = fakeModel();
|
|
407
|
+
assert.equal((await new CollaborationScheduler({ ...HOST, model: m2, persistence: { async load() { throw new Error("EACCES"); }, async save() {} } }).run({ runId: "lf", goal: "g" })).status, "persistence-failure");
|
|
408
|
+
const persistence = createMemoryPersistence();
|
|
409
|
+
await new CollaborationScheduler({ ...HOST, model: fakeModel(), persistence, limits: { maxRounds: 1 } }).run({ runId: "cor", goal: "g", needs: NEEDS });
|
|
410
|
+
for (const mutate of [s => { s.tokensUsed = -600; }, s => { s.version = 99; }, s => { s.tasks.core.dependsOn = ["ghost"]; }, s => { s.calls = { bad: {} }; }, s => { s.runId = "other"; }, s => { s.tasks.core.status = "approved-by-model"; }]) {
|
|
411
|
+
const base = await persistence.load("cor");
|
|
412
|
+
const tampered = structuredClone(base); mutate(tampered);
|
|
413
|
+
persistence._runs.set("cor", tampered);
|
|
414
|
+
const result = await new CollaborationScheduler({ ...HOST, model: m2, persistence }).run({ runId: "cor" });
|
|
415
|
+
assert.equal(result.status, "persistence-failure");
|
|
416
|
+
assert.match(result.stopReason, /persistence-corrupt/);
|
|
417
|
+
assert.deepEqual(persistence._runs.get("cor"), tampered, "corrupt evidence is not overwritten");
|
|
418
|
+
persistence._runs.set("cor", base);
|
|
419
|
+
}
|
|
420
|
+
assert.equal(m2.calls.length, 0);
|
|
421
|
+
assert.throws(() => validateRunState({ version: 1 }), /corrupt/);
|
|
422
|
+
await assert.rejects(new CollaborationScheduler({ ...HOST, model: m2, persistence: { async load() { return { version: 2 }; }, async save() {} } }).status("x"), /corrupt/);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("file persistence detects corruption and enforces injected integrity signatures", async () => {
|
|
426
|
+
const dir = mkdtempSync(join(tmpdir(), "collab-int-"));
|
|
427
|
+
try {
|
|
428
|
+
const integrity = hmacIntegrity("k".repeat(32));
|
|
429
|
+
assert.throws(() => hmacIntegrity("short"), /32 bytes/);
|
|
430
|
+
const signed = createFilePersistence(dir, { integrity });
|
|
431
|
+
assert.equal(signed.integrity, "signed");
|
|
432
|
+
assert.equal(createFilePersistence(dir).integrity, "checksum-only");
|
|
433
|
+
const done = await new CollaborationScheduler({ ...HOST, model: fakeModel(), persistence: signed }).run({ runId: "fp", goal: "g", needs: NEEDS });
|
|
434
|
+
assert.equal(done.status, "completed");
|
|
435
|
+
const file = join(dir, readdirSync(dir).find(f => f.endsWith(".json")));
|
|
436
|
+
const original = readFileSync(file, "utf8");
|
|
437
|
+
const envelope = JSON.parse(original);
|
|
438
|
+
assert.equal((await signed.load("fp")).status, "completed");
|
|
439
|
+
// 1. raw corruption -> checksum mismatch even without a signer
|
|
440
|
+
writeFileSync(file, JSON.stringify({ ...envelope, state: envelope.state.replace("completed", "cancelled") }));
|
|
441
|
+
await assert.rejects(createFilePersistence(dir).load("fp"), /checksum mismatch/);
|
|
442
|
+
// 2. attacker recomputes sha256 but cannot sign
|
|
443
|
+
const forged = envelope.state.replace(/"tokensUsed":\d+/, '"tokensUsed":0');
|
|
444
|
+
writeFileSync(file, JSON.stringify({ ...envelope, state: forged, sha256: createHash("sha256").update(forged).digest("hex") }));
|
|
445
|
+
await assert.rejects(signed.load("fp"), /signature invalid/);
|
|
446
|
+
// 3. stripped signature
|
|
447
|
+
writeFileSync(file, JSON.stringify({ ...envelope, signature: null }));
|
|
448
|
+
await assert.rejects(signed.load("fp"), /missing signature/);
|
|
449
|
+
const m = fakeModel();
|
|
450
|
+
const r = await new CollaborationScheduler({ ...HOST, model: m, persistence: signed }).run({ runId: "fp" });
|
|
451
|
+
assert.equal(r.status, "persistence-failure");
|
|
452
|
+
assert.equal(m.calls.length, 0);
|
|
453
|
+
writeFileSync(file, "not json");
|
|
454
|
+
await assert.rejects(signed.load("fp"), /not JSON/);
|
|
455
|
+
writeFileSync(file, original);
|
|
456
|
+
assert.equal((await signed.load("fp")).status, "completed");
|
|
457
|
+
assert.throws(() => createFilePersistence(dir, { integrity: { sign() {} } }), /verify/);
|
|
458
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
459
|
+
});
|