pi-vault-mind 0.8.0 → 0.8.3
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/CHANGELOG.md +20 -0
- package/README.md +44 -44
- package/agents/broadcaster.agent.md +30 -0
- package/agents/heavy-lifter.agent.md +30 -0
- package/agents/manager.agent.md +29 -0
- package/agents/miner.agent.md +29 -0
- package/dist/src/agent-bus/Agent.d.ts +49 -0
- package/dist/src/agent-bus/Agent.js +97 -0
- package/dist/src/agent-bus/AgentLoader.d.ts +33 -0
- package/dist/src/agent-bus/AgentLoader.js +102 -0
- package/dist/src/agent-bus/AgentRegistry.d.ts +17 -0
- package/dist/src/agent-bus/AgentRegistry.js +20 -0
- package/dist/src/agent-bus/LLMProvider.d.ts +36 -0
- package/dist/src/agent-bus/LLMProvider.js +125 -0
- package/dist/src/agent-bus/MessageBus.d.ts +47 -0
- package/dist/src/agent-bus/MessageBus.js +81 -0
- package/dist/src/agent-bus/TaskQueue.d.ts +27 -0
- package/dist/src/agent-bus/TaskQueue.js +57 -0
- package/dist/src/agent-bus/index.d.ts +6 -0
- package/dist/src/agent-bus/index.js +6 -0
- package/dist/src/agents/BroadcasterAgent.d.ts +19 -0
- package/dist/src/agents/BroadcasterAgent.js +70 -0
- package/dist/src/agents/HeavyLifterAgent.d.ts +26 -0
- package/dist/src/agents/HeavyLifterAgent.js +73 -0
- package/dist/src/agents/ManagerAgent.d.ts +20 -0
- package/dist/src/agents/ManagerAgent.js +71 -0
- package/dist/src/agents/MinerAgent.d.ts +19 -0
- package/dist/src/agents/MinerAgent.js +70 -0
- package/dist/src/agents/index.d.ts +4 -0
- package/dist/src/agents/index.js +4 -0
- package/dist/src/auth.d.ts +49 -2
- package/dist/src/auth.js +149 -7
- package/dist/src/autosync.d.ts +1 -1
- package/dist/src/autosync.js +3 -3
- package/dist/src/bridge.d.ts +45 -0
- package/dist/src/bridge.js +189 -0
- package/dist/src/commands.d.ts +1 -1
- package/dist/src/commands.js +419 -295
- package/dist/src/context-capture.d.ts +22 -0
- package/dist/src/context-capture.js +41 -0
- package/dist/src/discover-schema.d.ts +1 -1
- package/dist/src/discover-schema.js +4 -4
- package/dist/src/embed-queue.d.ts +1 -1
- package/dist/src/embed-queue.js +1 -1
- package/dist/src/engine.d.ts +65 -0
- package/dist/src/engine.js +244 -0
- package/dist/src/events.js +1 -1
- package/dist/src/graph.d.ts +3 -3
- package/dist/src/identity-injector.d.ts +15 -0
- package/dist/src/identity-injector.js +102 -0
- package/dist/src/index.js +88 -49
- package/dist/src/intake.js +4 -4
- package/dist/src/lance.d.ts +11 -37
- package/dist/src/lance.js +183 -240
- package/dist/src/modal-client.d.ts +14 -4
- package/dist/src/modal-client.js +50 -34
- package/dist/src/modal-config.d.ts +26 -10
- package/dist/src/modal-config.js +36 -5
- package/dist/src/personalize.d.ts +47 -0
- package/dist/src/personalize.js +352 -0
- package/dist/src/server.d.ts +30 -4
- package/dist/src/server.js +146 -64
- package/dist/src/settings-ui.d.ts +3 -1
- package/dist/src/settings-ui.js +84 -77
- package/dist/src/status.d.ts +25 -0
- package/dist/src/status.js +49 -0
- package/dist/src/sync.d.ts +1 -1
- package/dist/src/sync.js +19 -19
- package/dist/src/tombstone.js +2 -2
- package/dist/src/tools.js +112 -130
- package/dist/src/types.d.ts +86 -18
- package/dist/src/types.js +7 -4
- package/dist/src/utils.d.ts +5 -1
- package/dist/src/utils.js +24 -8
- package/dist/src/vault-tools.d.ts +29 -0
- package/dist/src/vault-tools.js +314 -0
- package/dist/src/vault-writer.d.ts +4 -5
- package/dist/src/vault-writer.js +13 -13
- package/dist/src/vm-handlers.d.ts +15 -0
- package/dist/src/vm-handlers.js +469 -0
- package/dist/src/watcher.d.ts +18 -4
- package/dist/src/watcher.js +109 -98
- package/dist/test/auth.test.js +57 -7
- package/dist/test/bridge-identity.test.js +388 -0
- package/dist/test/discover-schema.test.js +2 -2
- package/dist/test/engine-identity.test.js +119 -0
- package/dist/test/index.test.js +5 -6
- package/dist/test/lance-modal.test.js +7 -7
- package/dist/test/modal-config.test.js +51 -29
- package/dist/test/personalize.test.js +212 -0
- package/dist/test/rest-queue.test.js +1 -1
- package/dist/test/rest-setup.test.js +21 -21
- package/dist/test/{rest-wiki.test.js → rest-vm.test.js} +27 -23
- package/dist/test/server-ws.test.js +4 -4
- package/dist/test/sync.test.js +2 -2
- package/dist/test/tombstone.test.js +1 -1
- package/dist/test/vault-tools.test.js +224 -0
- package/package.json +2 -1
- package/scripts/check-deps.sh +207 -0
- package/scripts/e2e-ws.mjs +4 -4
- package/scripts/modal-e2e-smoke.mjs +1 -1
- package/scripts/setup-test-config.sh +1 -5
- package/scripts/test-slash-commands.mjs +197 -0
- package/skills/.agents/broadcaster.agent.md +31 -0
- package/skills/.agents/heavy-lifter.agent.md +31 -0
- package/skills/.agents/manager.agent.md +30 -0
- package/skills/.agents/miner.agent.md +30 -0
- package/skills/vault-mind/SKILL.md +50 -50
- package/skills/vault-mind/references/tool-reference.md +25 -25
- package/skills/vault-mind-broadcaster/SKILL.md +1 -1
- package/skills/vault-mind-heavy-lifter/SKILL.md +3 -3
- package/skills/vault-mind-manager/SKILL.md +7 -7
- package/skills/vault-mind-miner/SKILL.md +11 -11
- package/skills/vault-mind-setup/SKILL.md +65 -265
- package/skills/vault-mind-setup/references/obsidian-vault-structure.md +6 -6
- package/skills/vault-mind-setup/references/pi-extension-wiring.md +24 -33
- package/skills/vault-mind-setup/references/troubleshooting-tree.md +25 -26
- package/dist/src/auto-index.d.ts +0 -47
- package/dist/src/auto-index.js +0 -122
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0-F7: Integration tests for dispatch + identity gating.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. isToolAllowed — registry allowlist enforcement
|
|
6
|
+
* 2. buildIdentityFromRole — per-role baseline tool + publish scopes
|
|
7
|
+
* 3. handleVmAppend — writeCollections guard (via bridge dispatch)
|
|
8
|
+
* 4. handleVmSync / handleVmPromote — canPublish guard (via bridge dispatch)
|
|
9
|
+
*
|
|
10
|
+
* No module mocks: the real exported functions are called directly (items 1–2)
|
|
11
|
+
* or reached through the real bridge dispatch (items 3–4) so that removing any
|
|
12
|
+
* guard breaks the corresponding assertion.
|
|
13
|
+
*/
|
|
14
|
+
import { strict as assert } from "node:assert";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import * as fs from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
20
|
+
import { MessageBus } from "../src/agent-bus/MessageBus.js";
|
|
21
|
+
import { createBridge, isToolAllowed, registerAgentIdentity, unregisterAgentIdentity, } from "../src/bridge.js";
|
|
22
|
+
import { buildIdentityFromRole } from "../src/engine.js";
|
|
23
|
+
import { registerAllExtensionTools } from "../src/vm-handlers.js";
|
|
24
|
+
// ── Shared bus + bridge (one per file — module-level handlers are singletons) ─
|
|
25
|
+
const fakePi = {
|
|
26
|
+
sendUserMessage: () => { },
|
|
27
|
+
};
|
|
28
|
+
let bus;
|
|
29
|
+
before(() => {
|
|
30
|
+
bus = new MessageBus();
|
|
31
|
+
// Register vm_* handlers into the bridge module's extensionToolHandlers map.
|
|
32
|
+
registerAllExtensionTools(bus);
|
|
33
|
+
// Subscribe the bridge to pi_tool_bridge_requests on this bus.
|
|
34
|
+
createBridge(fakePi, bus);
|
|
35
|
+
});
|
|
36
|
+
// ── Bridge dispatch helper ────────────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Publish a tool request through the bridge and return the response payload.
|
|
39
|
+
* `response.error` — set when isToolAllowed rejected the request
|
|
40
|
+
* `response.result` — ToolResult from the handler ({ content, details })
|
|
41
|
+
*/
|
|
42
|
+
async function callTool(agentId, toolName, args) {
|
|
43
|
+
const requestId = randomUUID();
|
|
44
|
+
const responseTopic = `bridge_resp_${requestId}`;
|
|
45
|
+
let response;
|
|
46
|
+
const responseHandler = (msg) => {
|
|
47
|
+
response = msg.payload;
|
|
48
|
+
};
|
|
49
|
+
bus.subscribe(responseTopic, responseHandler);
|
|
50
|
+
await bus.publish("pi_tool_bridge_requests", "test-client", {
|
|
51
|
+
requestId,
|
|
52
|
+
agentId,
|
|
53
|
+
toolName,
|
|
54
|
+
args,
|
|
55
|
+
responseTopic,
|
|
56
|
+
});
|
|
57
|
+
bus.unsubscribe(responseTopic, responseHandler);
|
|
58
|
+
if (response === undefined)
|
|
59
|
+
throw new Error(`No bridge response for ${toolName}`);
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extract the first text item from a ToolResult response.
|
|
64
|
+
* If the bridge rejected at the isToolAllowed level the error is prefixed "Error:".
|
|
65
|
+
*/
|
|
66
|
+
function getResultText(resp) {
|
|
67
|
+
if (resp.error)
|
|
68
|
+
return `Error: ${resp.error}`;
|
|
69
|
+
const r = resp.result;
|
|
70
|
+
return r?.content?.[0]?.text ?? "";
|
|
71
|
+
}
|
|
72
|
+
// ── Config fixture ────────────────────────────────────────────────────────────
|
|
73
|
+
/** Write a minimal project config. dataDir must be an absolute path outside dir to avoid
|
|
74
|
+
* cleanup races with the background upsertEntry call in handleVmAppend. */
|
|
75
|
+
function writeProjectConfig(dir, dataDir) {
|
|
76
|
+
fs.writeFileSync(path.join(dir, "pi-vault-mind.config.json"), JSON.stringify({
|
|
77
|
+
version: 2,
|
|
78
|
+
collections: {
|
|
79
|
+
main: {
|
|
80
|
+
path: "collections/main.jsonl",
|
|
81
|
+
schema: ["id", "fact"],
|
|
82
|
+
dedupField: "fact",
|
|
83
|
+
},
|
|
84
|
+
research: {
|
|
85
|
+
path: "collections/research.jsonl",
|
|
86
|
+
schema: ["id", "fact"],
|
|
87
|
+
dedupField: "fact",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
injectors: [],
|
|
91
|
+
vaultMind: {
|
|
92
|
+
dataDir,
|
|
93
|
+
embedding: { provider: "transformers" },
|
|
94
|
+
ftsEnabled: false,
|
|
95
|
+
graph: { enabled: false },
|
|
96
|
+
},
|
|
97
|
+
}), "utf-8");
|
|
98
|
+
}
|
|
99
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
// 1. isToolAllowed — registry gating
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
describe("isToolAllowed — registry gating", () => {
|
|
103
|
+
const agentId = `test-allow-${randomUUID()}`;
|
|
104
|
+
before(() => {
|
|
105
|
+
registerAgentIdentity({
|
|
106
|
+
id: agentId,
|
|
107
|
+
role: "test",
|
|
108
|
+
allowedTools: ["read", "vm_search"],
|
|
109
|
+
writeCollections: [],
|
|
110
|
+
canPublish: false,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
after(() => {
|
|
114
|
+
unregisterAgentIdentity(agentId);
|
|
115
|
+
});
|
|
116
|
+
it("returns true for tools in the allowlist", () => {
|
|
117
|
+
assert.equal(isToolAllowed(agentId, "read"), true);
|
|
118
|
+
assert.equal(isToolAllowed(agentId, "vm_search"), true);
|
|
119
|
+
});
|
|
120
|
+
it("returns false for tools not in the allowlist", () => {
|
|
121
|
+
assert.equal(isToolAllowed(agentId, "vm_append"), false);
|
|
122
|
+
assert.equal(isToolAllowed(agentId, "vm_sync"), false);
|
|
123
|
+
assert.equal(isToolAllowed(agentId, "bash"), false);
|
|
124
|
+
});
|
|
125
|
+
it("returns false for an unknown agent id", () => {
|
|
126
|
+
assert.equal(isToolAllowed(`no-such-agent-${randomUUID()}`, "read"), false);
|
|
127
|
+
});
|
|
128
|
+
it("unregisterAgentIdentity removes the entry so isToolAllowed returns false", () => {
|
|
129
|
+
const ephId = `ephemeral-${randomUUID()}`;
|
|
130
|
+
registerAgentIdentity({
|
|
131
|
+
id: ephId,
|
|
132
|
+
role: "test",
|
|
133
|
+
allowedTools: ["read"],
|
|
134
|
+
writeCollections: [],
|
|
135
|
+
canPublish: false,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(isToolAllowed(ephId, "read"), true, "should be allowed before unregister");
|
|
138
|
+
unregisterAgentIdentity(ephId);
|
|
139
|
+
assert.equal(isToolAllowed(ephId, "read"), false, "should be denied after unregister");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// 2. Role baselines via buildIdentityFromRole
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
describe("buildIdentityFromRole — per-role gating baselines", () => {
|
|
146
|
+
const ids = {
|
|
147
|
+
heavyLifter: `hl-${randomUUID()}`,
|
|
148
|
+
miner: `miner-${randomUUID()}`,
|
|
149
|
+
manager: `mgr-${randomUUID()}`,
|
|
150
|
+
};
|
|
151
|
+
before(() => {
|
|
152
|
+
registerAgentIdentity(buildIdentityFromRole("heavy-lifter", ids.heavyLifter));
|
|
153
|
+
registerAgentIdentity(buildIdentityFromRole("miner", ids.miner));
|
|
154
|
+
registerAgentIdentity(buildIdentityFromRole("manager", ids.manager));
|
|
155
|
+
});
|
|
156
|
+
after(() => {
|
|
157
|
+
for (const id of Object.values(ids))
|
|
158
|
+
unregisterAgentIdentity(id);
|
|
159
|
+
});
|
|
160
|
+
it("heavy-lifter: vm_append and vm_sync are NOT allowed", () => {
|
|
161
|
+
assert.equal(isToolAllowed(ids.heavyLifter, "vm_append"), false, "heavy-lifter must not get vm_append");
|
|
162
|
+
assert.equal(isToolAllowed(ids.heavyLifter, "vm_sync"), false, "heavy-lifter must not get vm_sync");
|
|
163
|
+
});
|
|
164
|
+
it("miner: vm_append is allowed; vm_sync is NOT", () => {
|
|
165
|
+
assert.equal(isToolAllowed(ids.miner, "vm_append"), true, "miner needs vm_append");
|
|
166
|
+
assert.equal(isToolAllowed(ids.miner, "vm_sync"), false, "miner must not get vm_sync");
|
|
167
|
+
});
|
|
168
|
+
it("manager: vm_sync is allowed AND canPublish===true", () => {
|
|
169
|
+
assert.equal(isToolAllowed(ids.manager, "vm_sync"), true, "manager needs vm_sync");
|
|
170
|
+
const mgr = buildIdentityFromRole("manager", "any");
|
|
171
|
+
assert.equal(mgr.canPublish, true, "manager canPublish must be true");
|
|
172
|
+
});
|
|
173
|
+
it("miner: writeCollections is exactly ['main', 'research']", () => {
|
|
174
|
+
const miner = buildIdentityFromRole("miner", "any");
|
|
175
|
+
assert.deepEqual(miner.writeCollections, ["main", "research"]);
|
|
176
|
+
});
|
|
177
|
+
it("heavy-lifter: writeCollections is empty", () => {
|
|
178
|
+
const hl = buildIdentityFromRole("heavy-lifter", "any");
|
|
179
|
+
assert.deepEqual(hl.writeCollections, []);
|
|
180
|
+
assert.equal(hl.canPublish, false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
// 3. handleVmAppend — writeCollections guard (via bridge dispatch)
|
|
185
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
describe("handleVmAppend — writeCollections guard", () => {
|
|
187
|
+
let testDir;
|
|
188
|
+
let lanceDir;
|
|
189
|
+
let origCwd;
|
|
190
|
+
let origHome;
|
|
191
|
+
const agentIdDenied = `vm-append-denied-${randomUUID()}`;
|
|
192
|
+
const agentIdAllowed = `vm-append-allowed-${randomUUID()}`;
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
origCwd = process.cwd();
|
|
195
|
+
origHome = process.env.HOME;
|
|
196
|
+
testDir = path.join(tmpdir(), `pvm-bridge-id-${randomUUID()}`);
|
|
197
|
+
// Keep lancedb data outside testDir so the background async upsert
|
|
198
|
+
// can't race with rmSync(testDir) during afterEach cleanup.
|
|
199
|
+
lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
|
|
200
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
201
|
+
fs.mkdirSync(lanceDir, { recursive: true });
|
|
202
|
+
writeProjectConfig(testDir, lanceDir);
|
|
203
|
+
// Isolate from the user's global vault config so loadConfig only
|
|
204
|
+
// sees the project config we wrote above.
|
|
205
|
+
process.env.HOME = testDir;
|
|
206
|
+
process.env.USERPROFILE = testDir;
|
|
207
|
+
process.chdir(testDir);
|
|
208
|
+
});
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
process.chdir(origCwd);
|
|
211
|
+
if (origHome !== undefined) {
|
|
212
|
+
process.env.HOME = origHome;
|
|
213
|
+
process.env.USERPROFILE = origHome;
|
|
214
|
+
}
|
|
215
|
+
unregisterAgentIdentity(agentIdDenied);
|
|
216
|
+
unregisterAgentIdentity(agentIdAllowed);
|
|
217
|
+
if (fs.existsSync(testDir))
|
|
218
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
219
|
+
if (fs.existsSync(lanceDir))
|
|
220
|
+
fs.rmSync(lanceDir, { recursive: true, force: true });
|
|
221
|
+
});
|
|
222
|
+
it("rejects when collection is not in writeCollections", async () => {
|
|
223
|
+
registerAgentIdentity({
|
|
224
|
+
id: agentIdDenied,
|
|
225
|
+
role: "heavy-lifter",
|
|
226
|
+
allowedTools: ["vm_append"],
|
|
227
|
+
writeCollections: [], // no write access — "main" excluded
|
|
228
|
+
canPublish: false,
|
|
229
|
+
});
|
|
230
|
+
const resp = await callTool(agentIdDenied, "vm_append", {
|
|
231
|
+
collection: "main",
|
|
232
|
+
entry: { id: "x1", fact: "guarded test" },
|
|
233
|
+
});
|
|
234
|
+
const text = getResultText(resp);
|
|
235
|
+
assert.ok(text.startsWith("Error:"), `Expected an error response, got: "${text}"`);
|
|
236
|
+
assert.ok(text.includes("not allowed"), `Expected "not allowed" in error, got: "${text}"`);
|
|
237
|
+
});
|
|
238
|
+
it("succeeds when collection is in writeCollections", async () => {
|
|
239
|
+
registerAgentIdentity({
|
|
240
|
+
id: agentIdAllowed,
|
|
241
|
+
role: "miner",
|
|
242
|
+
allowedTools: ["vm_append"],
|
|
243
|
+
writeCollections: ["main", "research"], // has write access
|
|
244
|
+
canPublish: false,
|
|
245
|
+
});
|
|
246
|
+
const resp = await callTool(agentIdAllowed, "vm_append", {
|
|
247
|
+
collection: "main",
|
|
248
|
+
entry: { id: "x2", fact: "allowed append" },
|
|
249
|
+
});
|
|
250
|
+
const text = getResultText(resp);
|
|
251
|
+
assert.ok(!text.startsWith("Error:"), `Expected success, got: "${text}"`);
|
|
252
|
+
assert.ok(text.includes("Appended"), `Expected "Appended" in success text, got: "${text}"`);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
256
|
+
// 4a. handleVmSync — canPublish guard (via bridge dispatch)
|
|
257
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
258
|
+
describe("handleVmSync — canPublish guard", () => {
|
|
259
|
+
let testDir;
|
|
260
|
+
let lanceDir;
|
|
261
|
+
let origCwd;
|
|
262
|
+
let origHome;
|
|
263
|
+
const agentIdDenied = `vm-sync-denied-${randomUUID()}`;
|
|
264
|
+
const agentIdAllowed = `vm-sync-allowed-${randomUUID()}`;
|
|
265
|
+
beforeEach(() => {
|
|
266
|
+
origCwd = process.cwd();
|
|
267
|
+
origHome = process.env.HOME;
|
|
268
|
+
testDir = path.join(tmpdir(), `pvm-bridge-sync-${randomUUID()}`);
|
|
269
|
+
lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
|
|
270
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
271
|
+
fs.mkdirSync(lanceDir, { recursive: true });
|
|
272
|
+
writeProjectConfig(testDir, lanceDir);
|
|
273
|
+
// Isolate from the user's global vault config.
|
|
274
|
+
process.env.HOME = testDir;
|
|
275
|
+
process.env.USERPROFILE = testDir;
|
|
276
|
+
process.chdir(testDir);
|
|
277
|
+
});
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
process.chdir(origCwd);
|
|
280
|
+
if (origHome !== undefined) {
|
|
281
|
+
process.env.HOME = origHome;
|
|
282
|
+
process.env.USERPROFILE = origHome;
|
|
283
|
+
}
|
|
284
|
+
unregisterAgentIdentity(agentIdDenied);
|
|
285
|
+
unregisterAgentIdentity(agentIdAllowed);
|
|
286
|
+
if (fs.existsSync(testDir))
|
|
287
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
288
|
+
if (fs.existsSync(lanceDir))
|
|
289
|
+
fs.rmSync(lanceDir, { recursive: true, force: true });
|
|
290
|
+
});
|
|
291
|
+
it("rejects with authorization error when canPublish===false", async () => {
|
|
292
|
+
// Custom identity: vm_sync in allowlist so the bridge lets it through,
|
|
293
|
+
// but canPublish=false so the handler's guard fires.
|
|
294
|
+
registerAgentIdentity({
|
|
295
|
+
id: agentIdDenied,
|
|
296
|
+
role: "miner",
|
|
297
|
+
allowedTools: ["vm_sync"],
|
|
298
|
+
writeCollections: [],
|
|
299
|
+
canPublish: false,
|
|
300
|
+
});
|
|
301
|
+
const resp = await callTool(agentIdDenied, "vm_sync", {
|
|
302
|
+
vault: "default",
|
|
303
|
+
});
|
|
304
|
+
const text = getResultText(resp);
|
|
305
|
+
assert.ok(text.startsWith("Error:"), `Expected an auth error, got: "${text}"`);
|
|
306
|
+
assert.ok(text.includes("not authorized to publish"), `Expected "not authorized to publish", got: "${text}"`);
|
|
307
|
+
});
|
|
308
|
+
it("passes the canPublish guard when canPublish===true (manager role)", async () => {
|
|
309
|
+
const mgr = buildIdentityFromRole("manager", agentIdAllowed);
|
|
310
|
+
registerAgentIdentity(mgr);
|
|
311
|
+
const resp = await callTool(agentIdAllowed, "vm_sync", {
|
|
312
|
+
vault: "default",
|
|
313
|
+
});
|
|
314
|
+
const text = getResultText(resp);
|
|
315
|
+
// The auth guard must NOT fire. Any other error (e.g. no vaults
|
|
316
|
+
// configured) is acceptable; the important thing is the guard let
|
|
317
|
+
// the manager through.
|
|
318
|
+
assert.ok(!text.includes("not authorized to publish"), `Got unexpected auth error: "${text}"`);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
// 4b. handleVmPromote — canPublish guard (via bridge dispatch)
|
|
323
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
324
|
+
describe("handleVmPromote — canPublish guard", () => {
|
|
325
|
+
let testDir;
|
|
326
|
+
let lanceDir;
|
|
327
|
+
let origCwd;
|
|
328
|
+
let origHome;
|
|
329
|
+
const agentIdDenied = `vm-promote-denied-${randomUUID()}`;
|
|
330
|
+
const agentIdAllowed = `vm-promote-allowed-${randomUUID()}`;
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
origCwd = process.cwd();
|
|
333
|
+
origHome = process.env.HOME;
|
|
334
|
+
testDir = path.join(tmpdir(), `pvm-bridge-promo-${randomUUID()}`);
|
|
335
|
+
lanceDir = path.join(tmpdir(), `pvm-lance-${randomUUID()}`);
|
|
336
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
337
|
+
fs.mkdirSync(lanceDir, { recursive: true });
|
|
338
|
+
writeProjectConfig(testDir, lanceDir);
|
|
339
|
+
process.env.HOME = testDir;
|
|
340
|
+
process.env.USERPROFILE = testDir;
|
|
341
|
+
process.chdir(testDir);
|
|
342
|
+
});
|
|
343
|
+
afterEach(() => {
|
|
344
|
+
process.chdir(origCwd);
|
|
345
|
+
if (origHome !== undefined) {
|
|
346
|
+
process.env.HOME = origHome;
|
|
347
|
+
process.env.USERPROFILE = origHome;
|
|
348
|
+
}
|
|
349
|
+
unregisterAgentIdentity(agentIdDenied);
|
|
350
|
+
unregisterAgentIdentity(agentIdAllowed);
|
|
351
|
+
if (fs.existsSync(testDir))
|
|
352
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
353
|
+
if (fs.existsSync(lanceDir))
|
|
354
|
+
fs.rmSync(lanceDir, { recursive: true, force: true });
|
|
355
|
+
});
|
|
356
|
+
it("rejects with authorization error when canPublish===false", async () => {
|
|
357
|
+
registerAgentIdentity({
|
|
358
|
+
id: agentIdDenied,
|
|
359
|
+
role: "miner",
|
|
360
|
+
allowedTools: ["vm_promote"],
|
|
361
|
+
writeCollections: [],
|
|
362
|
+
canPublish: false,
|
|
363
|
+
});
|
|
364
|
+
const resp = await callTool(agentIdDenied, "vm_promote", {
|
|
365
|
+
sourceCollection: "main",
|
|
366
|
+
targetCollection: "research",
|
|
367
|
+
entryIds: [],
|
|
368
|
+
reason: "test",
|
|
369
|
+
});
|
|
370
|
+
const text = getResultText(resp);
|
|
371
|
+
assert.ok(text.startsWith("Error:"), `Expected an auth error, got: "${text}"`);
|
|
372
|
+
assert.ok(text.includes("not authorized to publish"), `Expected "not authorized to publish", got: "${text}"`);
|
|
373
|
+
});
|
|
374
|
+
it("passes the canPublish guard when canPublish===true (manager role)", async () => {
|
|
375
|
+
const mgr = buildIdentityFromRole("manager", agentIdAllowed);
|
|
376
|
+
registerAgentIdentity(mgr);
|
|
377
|
+
const resp = await callTool(agentIdAllowed, "vm_promote", {
|
|
378
|
+
sourceCollection: "main",
|
|
379
|
+
targetCollection: "research",
|
|
380
|
+
entryIds: [],
|
|
381
|
+
reason: "test",
|
|
382
|
+
});
|
|
383
|
+
const text = getResultText(resp);
|
|
384
|
+
// Guard passed; handler may error for another reason (e.g. unknown
|
|
385
|
+
// collection) but must NOT be the canPublish auth error.
|
|
386
|
+
assert.ok(!text.includes("not authorized to publish"), `Got unexpected auth error: "${text}"`);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -135,7 +135,7 @@ describe("discover-schema", () => {
|
|
|
135
135
|
const cfgPath = path.join(testDir, "pi-vault-mind.config.json");
|
|
136
136
|
fs.writeFileSync(cfgPath, JSON.stringify({
|
|
137
137
|
version: 2,
|
|
138
|
-
|
|
138
|
+
vaultMind: { dataDir: ".lancedb" },
|
|
139
139
|
collections: {
|
|
140
140
|
main: { path: "collections/main.jsonl", schema: ["id", "fact"] },
|
|
141
141
|
},
|
|
@@ -145,7 +145,7 @@ describe("discover-schema", () => {
|
|
|
145
145
|
runDiscoverSchema(testDir, filePath, "new");
|
|
146
146
|
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
147
147
|
assert.equal(cfg.version, 2);
|
|
148
|
-
assert.equal(cfg.
|
|
148
|
+
assert.equal(cfg.vaultMind.dataDir, ".lancedb");
|
|
149
149
|
assert.ok(cfg.collections.main);
|
|
150
150
|
assert.ok(cfg.collections.new);
|
|
151
151
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { buildIdentityFromRole, mergeIdentityProfiles } from "../src/engine.js";
|
|
4
|
+
// IdentityConfig requires several fields but at runtime the merge consults
|
|
5
|
+
// only allowedTools / writeCollections / canPublish. Tests deliberately pass
|
|
6
|
+
// partials to mirror what users put in JSON.
|
|
7
|
+
const profile = (over) => ({
|
|
8
|
+
role: "miner",
|
|
9
|
+
readCollections: [],
|
|
10
|
+
allowedVaultPaths: [],
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
describe("engine identity profiles", () => {
|
|
14
|
+
describe("buildIdentityFromRole — hard-coded fallback baseline", () => {
|
|
15
|
+
it("miner has vm_append and main/research write scope", () => {
|
|
16
|
+
const id = buildIdentityFromRole("miner", "miner-1");
|
|
17
|
+
assert.equal(id.id, "miner-1");
|
|
18
|
+
assert.equal(id.role, "miner");
|
|
19
|
+
assert.deepEqual(id.writeCollections, ["main", "research"]);
|
|
20
|
+
assert.equal(id.canPublish, false);
|
|
21
|
+
assert.ok(id.allowedTools.includes("vm_append"));
|
|
22
|
+
assert.ok(!id.allowedTools.includes("vm_sync"));
|
|
23
|
+
});
|
|
24
|
+
it("heavy-lifter cannot vm_append or vm_sync (hand to Miner)", () => {
|
|
25
|
+
const id = buildIdentityFromRole("heavy-lifter", "hl-1");
|
|
26
|
+
assert.ok(!id.allowedTools.includes("vm_append"));
|
|
27
|
+
assert.ok(!id.allowedTools.includes("vm_sync"));
|
|
28
|
+
assert.ok(id.allowedTools.includes("bash"));
|
|
29
|
+
assert.deepEqual(id.writeCollections, []);
|
|
30
|
+
assert.equal(id.canPublish, false);
|
|
31
|
+
});
|
|
32
|
+
it("manager is the only role with vm_sync + canPublish", () => {
|
|
33
|
+
const id = buildIdentityFromRole("manager", "mgr-1");
|
|
34
|
+
assert.ok(id.allowedTools.includes("vm_sync"));
|
|
35
|
+
assert.equal(id.canPublish, true);
|
|
36
|
+
});
|
|
37
|
+
it("unknown role falls back to read-only baseline", () => {
|
|
38
|
+
const id = buildIdentityFromRole("nope", "x-1");
|
|
39
|
+
assert.deepEqual(id.allowedTools, ["read", "vm_search", "vm_fts_search"]);
|
|
40
|
+
assert.deepEqual(id.writeCollections, []);
|
|
41
|
+
assert.equal(id.canPublish, false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("mergeIdentityProfiles — config-driven overrides", () => {
|
|
45
|
+
const baseMiner = () => buildIdentityFromRole("miner", "miner-1");
|
|
46
|
+
it("no config layers → identical to base (byte-equal today's behavior)", () => {
|
|
47
|
+
const base = baseMiner();
|
|
48
|
+
const merged = mergeIdentityProfiles(base, undefined, undefined);
|
|
49
|
+
assert.deepEqual(merged, base);
|
|
50
|
+
// must be a copy, not the same reference (callers may mutate).
|
|
51
|
+
assert.notEqual(merged, base);
|
|
52
|
+
});
|
|
53
|
+
it("defaultProfile overrides hard-coded allowedTools", () => {
|
|
54
|
+
const base = baseMiner();
|
|
55
|
+
const merged = mergeIdentityProfiles(base, profile({ allowedTools: ["read", "vm_search"] }), undefined);
|
|
56
|
+
assert.deepEqual(merged.allowedTools, ["read", "vm_search"]);
|
|
57
|
+
// fields the layer is silent on stay at the base value.
|
|
58
|
+
assert.deepEqual(merged.writeCollections, base.writeCollections);
|
|
59
|
+
assert.equal(merged.canPublish, base.canPublish);
|
|
60
|
+
});
|
|
61
|
+
it("roleProfile beats defaultProfile (last wins, per field)", () => {
|
|
62
|
+
const base = baseMiner();
|
|
63
|
+
const merged = mergeIdentityProfiles(base, profile({
|
|
64
|
+
allowedTools: ["read"],
|
|
65
|
+
writeCollections: ["from-default"],
|
|
66
|
+
canPublish: true,
|
|
67
|
+
}), profile({
|
|
68
|
+
writeCollections: ["from-role"],
|
|
69
|
+
canPublish: false,
|
|
70
|
+
}));
|
|
71
|
+
// defaultProfile won where roleProfile was silent.
|
|
72
|
+
assert.deepEqual(merged.allowedTools, ["read"]);
|
|
73
|
+
// roleProfile won where it was set.
|
|
74
|
+
assert.deepEqual(merged.writeCollections, ["from-role"]);
|
|
75
|
+
assert.equal(merged.canPublish, false);
|
|
76
|
+
});
|
|
77
|
+
it("acceptance: setting roles.miner.writeCollections rescopes Miner with no code change", () => {
|
|
78
|
+
const base = baseMiner();
|
|
79
|
+
const merged = mergeIdentityProfiles(base, undefined, profile({ writeCollections: ["custom-only"] }));
|
|
80
|
+
assert.deepEqual(merged.writeCollections, ["custom-only"]);
|
|
81
|
+
// other fields untouched — allowlist still has vm_append from the hard-coded base.
|
|
82
|
+
assert.ok(merged.allowedTools.includes("vm_append"));
|
|
83
|
+
assert.equal(merged.canPublish, false);
|
|
84
|
+
});
|
|
85
|
+
it("id and role are not overridable by config (always come from base)", () => {
|
|
86
|
+
const base = baseMiner();
|
|
87
|
+
const merged = mergeIdentityProfiles(base, profile({ role: "imposter" }), profile({ role: "still-imposter" }));
|
|
88
|
+
assert.equal(merged.id, "miner-1");
|
|
89
|
+
assert.equal(merged.role, "miner");
|
|
90
|
+
});
|
|
91
|
+
it("merges P2/P3 fields (readCollections, egressAllowlist) alongside P0-F4 fields", () => {
|
|
92
|
+
const base = baseMiner();
|
|
93
|
+
const merged = mergeIdentityProfiles(base, undefined, profile({
|
|
94
|
+
readCollections: ["secret"],
|
|
95
|
+
allowedVaultPaths: ["/etc"],
|
|
96
|
+
tokenRef: "PVM_TOKEN_MINER",
|
|
97
|
+
egressAllowlist: ["example.com"],
|
|
98
|
+
}));
|
|
99
|
+
assert.deepEqual(merged.allowedVaultPaths, ["/etc"]);
|
|
100
|
+
assert.deepEqual(merged.readCollections, ["secret"]);
|
|
101
|
+
assert.deepEqual(merged.egressAllowlist, ["example.com"]);
|
|
102
|
+
assert.deepEqual(Object.keys(merged).sort(), [
|
|
103
|
+
"allowedTools",
|
|
104
|
+
"allowedVaultPaths",
|
|
105
|
+
"canPublish",
|
|
106
|
+
"egressAllowlist",
|
|
107
|
+
"id",
|
|
108
|
+
"readCollections",
|
|
109
|
+
"role",
|
|
110
|
+
"writeCollections",
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
it("empty arrays in a layer override a non-empty base (intentional rescope, not silent skip)", () => {
|
|
114
|
+
const base = baseMiner();
|
|
115
|
+
const merged = mergeIdentityProfiles(base, undefined, profile({ writeCollections: [] }));
|
|
116
|
+
assert.deepEqual(merged.writeCollections, []);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
package/dist/test/index.test.js
CHANGED
|
@@ -69,12 +69,11 @@ describe("pi-vault-mind", () => {
|
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
injectors: [],
|
|
72
|
-
|
|
72
|
+
vaultMind: {
|
|
73
73
|
dataDir: ".lancedb",
|
|
74
74
|
embedding: {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
ollamaHost: "http://127.0.0.1:11434",
|
|
75
|
+
model: "embeddinggemma",
|
|
76
|
+
localUrl: "http://127.0.0.1:11434",
|
|
78
77
|
},
|
|
79
78
|
graph: { enabled: false, canvasSync: false },
|
|
80
79
|
ftsEnabled: false,
|
|
@@ -85,7 +84,7 @@ describe("pi-vault-mind", () => {
|
|
|
85
84
|
assert.ok(cfg, "config should be loaded");
|
|
86
85
|
assert.equal(cfg.version, 2);
|
|
87
86
|
assert.ok(cfg.collections.main, "should have main collection");
|
|
88
|
-
assert.equal(cfg.
|
|
87
|
+
assert.equal(cfg.vaultMind.embedding.model, "embeddinggemma");
|
|
89
88
|
}
|
|
90
89
|
finally {
|
|
91
90
|
process.chdir(cwd);
|
|
@@ -100,7 +99,7 @@ describe("pi-vault-mind", () => {
|
|
|
100
99
|
assert.equal(types.DEFAULT_CONFIG.version, 2);
|
|
101
100
|
assert.ok(types.DEFAULT_CONFIG.collections, "collections should exist");
|
|
102
101
|
assert.ok(types.DEFAULT_CONFIG.collections.main, "should have main collection");
|
|
103
|
-
assert.equal(types.DEFAULT_CONFIG.
|
|
102
|
+
assert.equal(types.DEFAULT_CONFIG.vaultMind.embedding.model, "embeddinggemma");
|
|
104
103
|
}
|
|
105
104
|
finally {
|
|
106
105
|
process.chdir(cwd);
|
|
@@ -8,10 +8,10 @@ import { connect, resetConnection, searchFts, searchHybrid, upsertPrecomputed, }
|
|
|
8
8
|
import { MODAL_TOKEN_ENV } from "../src/modal-config.js";
|
|
9
9
|
const MODEL = "testmodel";
|
|
10
10
|
const DIM = 3;
|
|
11
|
-
const
|
|
11
|
+
const makeCfg = (dataDir) => ({
|
|
12
12
|
dataDir,
|
|
13
13
|
embedding: {
|
|
14
|
-
|
|
14
|
+
remoteUrl: "https://pvm.modal.run",
|
|
15
15
|
modal: { baseUrl: "https://pvm.modal.run", model: MODEL },
|
|
16
16
|
coalesce: { debounceMs: 5, maxBatchSize: 1, maxConcurrentFlushes: 1 },
|
|
17
17
|
},
|
|
@@ -48,14 +48,14 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
|
|
|
48
48
|
resetConnection();
|
|
49
49
|
});
|
|
50
50
|
it("upsertPrecomputed imports rows into the namespaced table", async () => {
|
|
51
|
-
const cfg =
|
|
51
|
+
const cfg = makeCfg(dir);
|
|
52
52
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0])], cfg);
|
|
53
53
|
const conn = await connect(dir);
|
|
54
54
|
const table = await conn.openTable(`col_main__${MODEL}__${DIM}`);
|
|
55
55
|
assert.equal(await table.countRows(), 2);
|
|
56
56
|
});
|
|
57
57
|
it("upsertPrecomputed is idempotent (merge-insert by id)", async () => {
|
|
58
|
-
const cfg =
|
|
58
|
+
const cfg = makeCfg(dir);
|
|
59
59
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
|
|
60
60
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0])], cfg);
|
|
61
61
|
const conn = await connect(dir);
|
|
@@ -63,7 +63,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
|
|
|
63
63
|
assert.equal(await table.countRows(), 1);
|
|
64
64
|
});
|
|
65
65
|
it("searchHybrid (modal) returns nearest results via the /embed query path", async () => {
|
|
66
|
-
const cfg =
|
|
66
|
+
const cfg = makeCfg(dir);
|
|
67
67
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0]), syncRow("2", [0, 1, 0]), syncRow("3", [0, 0, 1])], cfg);
|
|
68
68
|
// Mock /embed to return a query vector matching row "1".
|
|
69
69
|
globalThis.fetch = (async () => ({
|
|
@@ -77,7 +77,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
|
|
|
77
77
|
assert.equal(results[0].id, "1", "nearest neighbor is row 1");
|
|
78
78
|
});
|
|
79
79
|
it("searchHybrid degrades gracefully (no crash) when Modal is offline", async () => {
|
|
80
|
-
const cfg =
|
|
80
|
+
const cfg = makeCfg(dir);
|
|
81
81
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "authentication tokens")], cfg);
|
|
82
82
|
// Offline: every fetch rejects.
|
|
83
83
|
globalThis.fetch = (async () => {
|
|
@@ -87,7 +87,7 @@ describe("Modal lance path (precomputed + search + fallback)", () => {
|
|
|
87
87
|
assert.ok(Array.isArray(results), "offline search returns an array, never throws");
|
|
88
88
|
});
|
|
89
89
|
it("searchFts (modal) targets the namespaced table without throwing", async () => {
|
|
90
|
-
const cfg =
|
|
90
|
+
const cfg = makeCfg(dir);
|
|
91
91
|
await upsertPrecomputed(dir, "main", MODEL, DIM, [syncRow("1", [1, 0, 0], "rare keyword match here")], cfg);
|
|
92
92
|
const results = await searchFts(dir, "main", "rare", 5, cfg);
|
|
93
93
|
assert.ok(Array.isArray(results));
|