prism-mcp-server 20.0.6 → 20.0.7
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/dist/agent/agentTools.js +453 -0
- package/dist/agent/mcpBridge.js +234 -0
- package/dist/agent/platformUtils.js +470 -0
- package/dist/agent/terminalUI.js +198 -0
- package/dist/auth.js +218 -0
- package/dist/darkfactory/cloudDelegate.js +173 -0
- package/dist/plugins/pluginManager.js +199 -0
- package/dist/prism-cloud.js +110 -0
- package/dist/scm/ciPipeline.js +220 -0
- package/dist/server.js +1 -1
- package/dist/storage/inferMetricsLedger.js +159 -0
- package/dist/sync/encryptedSync.js +172 -0
- package/dist/sync/synaluxProxy.js +177 -0
- package/dist/tools/__tests__/layer1Integration.test.js +34 -0
- package/dist/tools/adaptiveDefinitions.js +148 -0
- package/dist/tools/behavioralVerifierHandler.js +0 -9
- package/dist/tools/interactiveDefinitions.js +87 -0
- package/dist/tools/interactiveHandlers.js +164 -0
- package/dist/tools/ledgerHandlers.js +40 -13
- package/dist/tools/prismInferHandler.js +48 -6
- package/dist/tools/projects.js +214 -0
- package/dist/tools/sessionMemoryDefinitions.js +12 -5
- package/dist/tools/skillRouting.js +57 -8
- package/dist/utils/changelogGenerator.js +158 -0
- package/dist/utils/fallbackClient.js +52 -0
- package/dist/utils/groundingVerifier.js +3 -3
- package/dist/utils/inferenceMetrics.js +38 -1
- package/dist/utils/memoryAttestation.js +163 -0
- package/dist/utils/rbac.js +321 -0
- package/dist/utils/skillBudget.js +58 -0
- package/dist/utils/tavilyApi.js +70 -0
- package/dist/vm/quotaEnforcer.js +192 -0
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v12.4: GitHub Actions CI/CD Pipeline Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates GitHub Actions YAML workflows from Prism project configuration.
|
|
5
|
+
* Supports test suite integration, automated deployments, and custom triggers.
|
|
6
|
+
*/
|
|
7
|
+
import { debugLog } from "../utils/logger.js";
|
|
8
|
+
// ─── Preset Templates ───────────────────────────────────────
|
|
9
|
+
const PRESETS = {
|
|
10
|
+
"node-test": {
|
|
11
|
+
project: "",
|
|
12
|
+
triggers: [
|
|
13
|
+
{ type: "push", branches: ["main"] },
|
|
14
|
+
{ type: "pull_request", branches: ["main"] },
|
|
15
|
+
],
|
|
16
|
+
jobs: [
|
|
17
|
+
{
|
|
18
|
+
name: "test",
|
|
19
|
+
runsOn: "ubuntu-latest",
|
|
20
|
+
steps: [
|
|
21
|
+
{ name: "Checkout", uses: "actions/checkout@v4" },
|
|
22
|
+
{ name: "Setup Node", uses: "actions/setup-node@v4", with: { "node-version": "20" } },
|
|
23
|
+
{ name: "Install", run: "npm ci" },
|
|
24
|
+
{ name: "Lint", run: "npm run lint --if-present" },
|
|
25
|
+
{ name: "Type Check", run: "npx tsc --noEmit" },
|
|
26
|
+
{ name: "Test", run: "npm test --if-present" },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
"npm-publish": {
|
|
32
|
+
project: "",
|
|
33
|
+
triggers: [
|
|
34
|
+
{ type: "release", tags: ["v*"] },
|
|
35
|
+
],
|
|
36
|
+
jobs: [
|
|
37
|
+
{
|
|
38
|
+
name: "publish",
|
|
39
|
+
runsOn: "ubuntu-latest",
|
|
40
|
+
steps: [
|
|
41
|
+
{ name: "Checkout", uses: "actions/checkout@v4" },
|
|
42
|
+
{ name: "Setup Node", uses: "actions/setup-node@v4", with: { "node-version": "20", "registry-url": "https://registry.npmjs.org" } },
|
|
43
|
+
{ name: "Install", run: "npm ci" },
|
|
44
|
+
{ name: "Build", run: "npm run build" },
|
|
45
|
+
{ name: "Publish", run: "npm publish", env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" } },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
"python-test": {
|
|
51
|
+
project: "",
|
|
52
|
+
triggers: [
|
|
53
|
+
{ type: "push", branches: ["main"] },
|
|
54
|
+
{ type: "pull_request", branches: ["main"] },
|
|
55
|
+
],
|
|
56
|
+
jobs: [
|
|
57
|
+
{
|
|
58
|
+
name: "test",
|
|
59
|
+
runsOn: "ubuntu-latest",
|
|
60
|
+
steps: [
|
|
61
|
+
{ name: "Checkout", uses: "actions/checkout@v4" },
|
|
62
|
+
{ name: "Setup Python", uses: "actions/setup-python@v5", with: { "python-version": "3.12" } },
|
|
63
|
+
{ name: "Install", run: "pip install -e '.[dev]'" },
|
|
64
|
+
{ name: "Lint", run: "ruff check ." },
|
|
65
|
+
{ name: "Test", run: "pytest -v" },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
// ─── YAML Generator ──────────────────────────────────────────
|
|
72
|
+
function indent(level) {
|
|
73
|
+
return " ".repeat(level);
|
|
74
|
+
}
|
|
75
|
+
function renderTriggers(triggers) {
|
|
76
|
+
const lines = ["on:"];
|
|
77
|
+
for (const trigger of triggers) {
|
|
78
|
+
switch (trigger.type) {
|
|
79
|
+
case "push":
|
|
80
|
+
lines.push(`${indent(1)}push:`);
|
|
81
|
+
if (trigger.branches?.length) {
|
|
82
|
+
lines.push(`${indent(2)}branches: [${trigger.branches.map(b => `"${b}"`).join(", ")}]`);
|
|
83
|
+
}
|
|
84
|
+
if (trigger.paths?.length) {
|
|
85
|
+
lines.push(`${indent(2)}paths:`);
|
|
86
|
+
for (const p of trigger.paths)
|
|
87
|
+
lines.push(`${indent(3)}- "${p}"`);
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
case "pull_request":
|
|
91
|
+
lines.push(`${indent(1)}pull_request:`);
|
|
92
|
+
if (trigger.branches?.length) {
|
|
93
|
+
lines.push(`${indent(2)}branches: [${trigger.branches.map(b => `"${b}"`).join(", ")}]`);
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
case "schedule":
|
|
97
|
+
lines.push(`${indent(1)}schedule:`);
|
|
98
|
+
lines.push(`${indent(2)}- cron: "${trigger.cron || "0 0 * * *"}"`);
|
|
99
|
+
break;
|
|
100
|
+
case "workflow_dispatch":
|
|
101
|
+
lines.push(`${indent(1)}workflow_dispatch:`);
|
|
102
|
+
break;
|
|
103
|
+
case "release":
|
|
104
|
+
lines.push(`${indent(1)}release:`);
|
|
105
|
+
lines.push(`${indent(2)}types: [published]`);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
function renderStep(step, level) {
|
|
112
|
+
const lines = [];
|
|
113
|
+
lines.push(`${indent(level)}- name: "${step.name}"`);
|
|
114
|
+
if (step.condition) {
|
|
115
|
+
lines.push(`${indent(level + 1)}if: ${step.condition}`);
|
|
116
|
+
}
|
|
117
|
+
if (step.uses) {
|
|
118
|
+
lines.push(`${indent(level + 1)}uses: ${step.uses}`);
|
|
119
|
+
}
|
|
120
|
+
if (step.run) {
|
|
121
|
+
if (step.run.includes("\n")) {
|
|
122
|
+
lines.push(`${indent(level + 1)}run: |`);
|
|
123
|
+
for (const line of step.run.split("\n")) {
|
|
124
|
+
lines.push(`${indent(level + 2)}${line}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
lines.push(`${indent(level + 1)}run: ${step.run}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (step.with && Object.keys(step.with).length > 0) {
|
|
132
|
+
lines.push(`${indent(level + 1)}with:`);
|
|
133
|
+
for (const [k, v] of Object.entries(step.with)) {
|
|
134
|
+
lines.push(`${indent(level + 2)}${k}: "${v}"`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (step.env && Object.keys(step.env).length > 0) {
|
|
138
|
+
lines.push(`${indent(level + 1)}env:`);
|
|
139
|
+
for (const [k, v] of Object.entries(step.env)) {
|
|
140
|
+
lines.push(`${indent(level + 2)}${k}: ${v}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Generate a GitHub Actions YAML workflow from a pipeline config.
|
|
147
|
+
*/
|
|
148
|
+
export function generateWorkflow(config) {
|
|
149
|
+
const lines = [];
|
|
150
|
+
lines.push(`name: ${config.project || "CI"}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
lines.push(renderTriggers(config.triggers));
|
|
153
|
+
lines.push("");
|
|
154
|
+
if (config.env && Object.keys(config.env).length > 0) {
|
|
155
|
+
lines.push("env:");
|
|
156
|
+
for (const [k, v] of Object.entries(config.env)) {
|
|
157
|
+
lines.push(`${indent(1)}${k}: "${v}"`);
|
|
158
|
+
}
|
|
159
|
+
lines.push("");
|
|
160
|
+
}
|
|
161
|
+
if (config.concurrency) {
|
|
162
|
+
lines.push("concurrency:");
|
|
163
|
+
lines.push(`${indent(1)}group: ${config.concurrency.group}`);
|
|
164
|
+
lines.push(`${indent(1)}cancel-in-progress: ${config.concurrency.cancelInProgress}`);
|
|
165
|
+
lines.push("");
|
|
166
|
+
}
|
|
167
|
+
lines.push("jobs:");
|
|
168
|
+
for (const job of config.jobs) {
|
|
169
|
+
lines.push(`${indent(1)}${job.name.replace(/\s+/g, "-").toLowerCase()}:`);
|
|
170
|
+
lines.push(`${indent(2)}runs-on: ${job.runsOn}`);
|
|
171
|
+
if (job.timeout) {
|
|
172
|
+
lines.push(`${indent(2)}timeout-minutes: ${job.timeout}`);
|
|
173
|
+
}
|
|
174
|
+
if (job.needs?.length) {
|
|
175
|
+
lines.push(`${indent(2)}needs: [${job.needs.join(", ")}]`);
|
|
176
|
+
}
|
|
177
|
+
if (job.condition) {
|
|
178
|
+
lines.push(`${indent(2)}if: ${job.condition}`);
|
|
179
|
+
}
|
|
180
|
+
if (job.env && Object.keys(job.env).length > 0) {
|
|
181
|
+
lines.push(`${indent(2)}env:`);
|
|
182
|
+
for (const [k, v] of Object.entries(job.env)) {
|
|
183
|
+
lines.push(`${indent(3)}${k}: "${v}"`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
lines.push(`${indent(2)}steps:`);
|
|
187
|
+
for (const step of job.steps) {
|
|
188
|
+
lines.push(renderStep(step, 3));
|
|
189
|
+
}
|
|
190
|
+
lines.push("");
|
|
191
|
+
}
|
|
192
|
+
const yaml = lines.join("\n");
|
|
193
|
+
return {
|
|
194
|
+
filename: `.github/workflows/${config.project || "ci"}.yml`,
|
|
195
|
+
yaml,
|
|
196
|
+
description: `CI/CD workflow for ${config.project || "project"}`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Generate a workflow from a preset template.
|
|
201
|
+
*/
|
|
202
|
+
export function generateFromPreset(preset, project) {
|
|
203
|
+
const config = PRESETS[preset];
|
|
204
|
+
if (!config) {
|
|
205
|
+
debugLog(`CI Pipeline: Unknown preset '${preset}'. Available: ${Object.keys(PRESETS).join(", ")}`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return generateWorkflow({ ...config, project });
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* List available preset templates.
|
|
212
|
+
*/
|
|
213
|
+
export function listPresets() {
|
|
214
|
+
return [
|
|
215
|
+
{ name: "node-test", description: "Node.js CI: lint, type-check, test" },
|
|
216
|
+
{ name: "npm-publish", description: "npm publish on release tag" },
|
|
217
|
+
{ name: "python-test", description: "Python CI: ruff lint, pytest" },
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
debugLog("v12.4: CI pipeline generator loaded");
|
package/dist/server.js
CHANGED
|
@@ -985,7 +985,7 @@ export function createServer() {
|
|
|
985
985
|
result = await knowledgeIngestHandler(args);
|
|
986
986
|
break;
|
|
987
987
|
case "inference_metrics":
|
|
988
|
-
result = await inferenceMetricsHandler();
|
|
988
|
+
result = await inferenceMetricsHandler(args);
|
|
989
989
|
break;
|
|
990
990
|
default:
|
|
991
991
|
result = {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent inference-metrics ledger — append-only rows in the same
|
|
3
|
+
* ~/.prism-mcp/prism-config.db used by configStorage.
|
|
4
|
+
*
|
|
5
|
+
* Purpose: the in-memory counters in utils/inferenceMetrics.ts reset with
|
|
6
|
+
* every MCP server process, which made "how much do we actually delegate?"
|
|
7
|
+
* unanswerable. This ledger is the durable record that delegation goal
|
|
8
|
+
* metrics (local vs cloud volume over time) are computed from.
|
|
9
|
+
*
|
|
10
|
+
* Contract:
|
|
11
|
+
* - appendInferMetric() is fire-and-forget: it must NEVER throw or delay
|
|
12
|
+
* the inference hot path. Failures are debug-logged and dropped.
|
|
13
|
+
* - safety_gate calls are excluded by the caller (recordInference returns
|
|
14
|
+
* before reaching us) — crisis-filter triggers are never persisted.
|
|
15
|
+
* - gate_outcome / refusal_reason / caller are nullable now and filled by
|
|
16
|
+
* the Phase-1 failure contract without a schema migration.
|
|
17
|
+
*/
|
|
18
|
+
import { createClient } from "@libsql/client";
|
|
19
|
+
import { resolve, dirname } from "path";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { existsSync, mkdirSync } from "fs";
|
|
22
|
+
import { debugLog } from "../utils/logger.js";
|
|
23
|
+
// Resolution order:
|
|
24
|
+
// 1. PRISM_INFER_LEDGER_DB_PATH — explicit override (tests, relocation)
|
|
25
|
+
// 2. PRISM_DATA_DIR — the test-suite sandbox (tests/setup.ts) and any
|
|
26
|
+
// operator-relocated data root; REQUIRED so `npm test` never writes
|
|
27
|
+
// fabricated rows into the real user ledger
|
|
28
|
+
// 3. default ~/.prism-mcp/prism-config.db (shared with configStorage)
|
|
29
|
+
function dbPath() {
|
|
30
|
+
if (process.env.PRISM_INFER_LEDGER_DB_PATH)
|
|
31
|
+
return process.env.PRISM_INFER_LEDGER_DB_PATH;
|
|
32
|
+
if (process.env.PRISM_DATA_DIR)
|
|
33
|
+
return resolve(process.env.PRISM_DATA_DIR, "prism-config.db");
|
|
34
|
+
return resolve(homedir(), ".prism-mcp", "prism-config.db");
|
|
35
|
+
}
|
|
36
|
+
let client = null;
|
|
37
|
+
let ensured = null;
|
|
38
|
+
let disabled = false;
|
|
39
|
+
let initFailures = 0;
|
|
40
|
+
const MAX_INIT_FAILURES = 3;
|
|
41
|
+
function ensureTable() {
|
|
42
|
+
if (!ensured) {
|
|
43
|
+
ensured = (async () => {
|
|
44
|
+
const path = dbPath();
|
|
45
|
+
const dir = dirname(path);
|
|
46
|
+
if (!existsSync(dir))
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
client = createClient({ url: `file:${path}` });
|
|
49
|
+
// Shared file with configStorage — wait out short write locks
|
|
50
|
+
// instead of failing (transient SQLITE_BUSY must not kill the
|
|
51
|
+
// ledger). Best-effort: an unsupported PRAGMA must not disable us.
|
|
52
|
+
await client.execute(`PRAGMA busy_timeout = 2000`).catch(() => { });
|
|
53
|
+
await client.execute(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS infer_metrics (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
ts INTEGER NOT NULL,
|
|
57
|
+
caller TEXT,
|
|
58
|
+
mode TEXT,
|
|
59
|
+
backend TEXT NOT NULL,
|
|
60
|
+
model TEXT,
|
|
61
|
+
used_cloud INTEGER NOT NULL,
|
|
62
|
+
gate_outcome TEXT,
|
|
63
|
+
refusal_reason TEXT,
|
|
64
|
+
prompt_tokens INTEGER,
|
|
65
|
+
completion_tokens INTEGER,
|
|
66
|
+
latency_ms INTEGER,
|
|
67
|
+
ram_free_mb INTEGER
|
|
68
|
+
)`);
|
|
69
|
+
await client.execute(`CREATE INDEX IF NOT EXISTS idx_infer_metrics_ts ON infer_metrics (ts)`);
|
|
70
|
+
})().catch((e) => {
|
|
71
|
+
// Transient failures (missing dir on first run, SQLITE_BUSY) retry on
|
|
72
|
+
// the next append; only repeated failure disables for the process.
|
|
73
|
+
initFailures++;
|
|
74
|
+
ensured = null;
|
|
75
|
+
client = null;
|
|
76
|
+
if (initFailures >= MAX_INIT_FAILURES)
|
|
77
|
+
disabled = true;
|
|
78
|
+
debugLog(`[infer-ledger] init failed (${initFailures}/${MAX_INIT_FAILURES}${disabled ? ", ledger disabled" : ", will retry"}): ${e instanceof Error ? e.message : e}`);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return ensured;
|
|
82
|
+
}
|
|
83
|
+
/** Append one row. Fire-and-forget — never throws, never blocks the caller. */
|
|
84
|
+
export function appendInferMetric(row) {
|
|
85
|
+
if (disabled)
|
|
86
|
+
return;
|
|
87
|
+
void (async () => {
|
|
88
|
+
await ensureTable();
|
|
89
|
+
if (disabled || !client)
|
|
90
|
+
return;
|
|
91
|
+
await client.execute({
|
|
92
|
+
sql: `INSERT INTO infer_metrics
|
|
93
|
+
(ts, caller, mode, backend, model, used_cloud, gate_outcome,
|
|
94
|
+
refusal_reason, prompt_tokens, completion_tokens, latency_ms, ram_free_mb)
|
|
95
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
96
|
+
args: [
|
|
97
|
+
Date.now(), row.caller ?? "mcp", row.mode ?? null, row.backend,
|
|
98
|
+
row.model, row.used_cloud ? 1 : 0, row.gate_outcome ?? null,
|
|
99
|
+
row.refusal_reason ?? null, row.prompt_tokens ?? null,
|
|
100
|
+
row.completion_tokens ?? null, row.latency_ms ?? null,
|
|
101
|
+
row.ram_free_mb ?? null,
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
})().catch((e) => {
|
|
105
|
+
debugLog(`[infer-ledger] append failed: ${e instanceof Error ? e.message : e}`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Aggregate all persisted rows (optionally since a timestamp). */
|
|
109
|
+
export async function queryInferMetrics(sinceTs) {
|
|
110
|
+
try {
|
|
111
|
+
await ensureTable();
|
|
112
|
+
if (disabled || !client)
|
|
113
|
+
return null;
|
|
114
|
+
const where = sinceTs != null ? `WHERE ts >= ?` : "";
|
|
115
|
+
const whereArgs = sinceTs != null ? [Math.floor(sinceTs)] : [];
|
|
116
|
+
const agg = await client.execute({
|
|
117
|
+
sql: `
|
|
118
|
+
SELECT COUNT(*) AS total,
|
|
119
|
+
SUM(CASE WHEN used_cloud = 0 THEN 1 ELSE 0 END) AS local,
|
|
120
|
+
SUM(CASE WHEN used_cloud = 1 THEN 1 ELSE 0 END) AS cloud,
|
|
121
|
+
COALESCE(SUM(prompt_tokens), 0) AS pt,
|
|
122
|
+
COALESCE(SUM(completion_tokens), 0) AS ct,
|
|
123
|
+
COALESCE(AVG(latency_ms), 0) AS avg_lat,
|
|
124
|
+
MIN(ts) AS first_ts, MAX(ts) AS last_ts
|
|
125
|
+
FROM infer_metrics ${where}`, args: whereArgs
|
|
126
|
+
});
|
|
127
|
+
const byB = await client.execute({
|
|
128
|
+
sql: `SELECT backend, COUNT(*) AS n FROM infer_metrics ${where} GROUP BY backend`,
|
|
129
|
+
args: whereArgs
|
|
130
|
+
});
|
|
131
|
+
const r = agg.rows[0];
|
|
132
|
+
const by_backend = {};
|
|
133
|
+
for (const row of byB.rows) {
|
|
134
|
+
by_backend[String(row.backend)] = Number(row.n);
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
total: Number(r.total ?? 0),
|
|
138
|
+
local: Number(r.local ?? 0),
|
|
139
|
+
cloud: Number(r.cloud ?? 0),
|
|
140
|
+
prompt_tokens: Number(r.pt ?? 0),
|
|
141
|
+
completion_tokens: Number(r.ct ?? 0),
|
|
142
|
+
avg_latency_ms: Math.round(Number(r.avg_lat ?? 0)),
|
|
143
|
+
first_ts: r.first_ts == null ? null : Number(r.first_ts),
|
|
144
|
+
last_ts: r.last_ts == null ? null : Number(r.last_ts),
|
|
145
|
+
by_backend,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
debugLog(`[infer-ledger] query failed: ${e instanceof Error ? e.message : e}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Test hook — reset module state so a fresh DB path/env can be exercised. */
|
|
154
|
+
export function _resetInferLedgerForTest() {
|
|
155
|
+
client = null;
|
|
156
|
+
ensured = null;
|
|
157
|
+
disabled = false;
|
|
158
|
+
initFailures = 0;
|
|
159
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v12.3: Encrypted Peer-to-Peer Session Syncing
|
|
3
|
+
*
|
|
4
|
+
* AES-256-GCM encryption for session data transmission between
|
|
5
|
+
* Prism instances. Supports both stdio pipe and WebSocket transports.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* 1. Generate per-sync ephemeral key (ECDH key exchange)
|
|
9
|
+
* 2. Encrypt session payloads with AES-256-GCM
|
|
10
|
+
* 3. Transfer via stdio pipe (local) or WebSocket (remote)
|
|
11
|
+
* 4. Verify integrity with HMAC-SHA256
|
|
12
|
+
*/
|
|
13
|
+
import { debugLog } from "../utils/logger.js";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
const { createHash, randomBytes, createCipheriv, createDecipheriv } = crypto;
|
|
16
|
+
// ─── Encryption / Decryption ─────────────────────────────────
|
|
17
|
+
const ALGORITHM = "aes-256-gcm";
|
|
18
|
+
const IV_LENGTH = 12;
|
|
19
|
+
const KEY_LENGTH = 32;
|
|
20
|
+
/**
|
|
21
|
+
* Derive a symmetric key from a shared secret using SHA-256.
|
|
22
|
+
*/
|
|
23
|
+
export function deriveKey(sharedSecret) {
|
|
24
|
+
return createHash("sha256").update(sharedSecret).digest();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Generate a random encryption key.
|
|
28
|
+
*/
|
|
29
|
+
export function generateKey() {
|
|
30
|
+
return randomBytes(KEY_LENGTH);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encrypt a payload with AES-256-GCM.
|
|
34
|
+
*/
|
|
35
|
+
export function encrypt(plaintext, key) {
|
|
36
|
+
const iv = randomBytes(IV_LENGTH);
|
|
37
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
38
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
39
|
+
encrypted += cipher.final("hex");
|
|
40
|
+
return {
|
|
41
|
+
iv: iv.toString("hex"),
|
|
42
|
+
data: encrypted,
|
|
43
|
+
tag: cipher.getAuthTag().toString("hex"),
|
|
44
|
+
algorithm: ALGORITHM,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decrypt an AES-256-GCM encrypted packet.
|
|
49
|
+
*/
|
|
50
|
+
export function decrypt(packet, key) {
|
|
51
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(packet.iv, "hex"));
|
|
52
|
+
decipher.setAuthTag(Buffer.from(packet.tag, "hex"));
|
|
53
|
+
let decrypted = decipher.update(packet.data, "hex", "utf8");
|
|
54
|
+
decrypted += decipher.final("utf8");
|
|
55
|
+
return decrypted;
|
|
56
|
+
}
|
|
57
|
+
// ─── Sync Payload Construction ───────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Create a checksum for a sync payload (integrity verification).
|
|
60
|
+
*/
|
|
61
|
+
export function computeChecksum(entries) {
|
|
62
|
+
const hash = createHash("sha256");
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
hash.update(entry.id);
|
|
65
|
+
hash.update(entry.data);
|
|
66
|
+
}
|
|
67
|
+
return hash.digest("hex");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a sync payload from session entries.
|
|
71
|
+
*/
|
|
72
|
+
export function buildSyncPayload(sourceId, targetId, entries) {
|
|
73
|
+
return {
|
|
74
|
+
version: 1,
|
|
75
|
+
sourceId,
|
|
76
|
+
targetId,
|
|
77
|
+
timestamp: new Date().toISOString(),
|
|
78
|
+
entries,
|
|
79
|
+
checksum: computeChecksum(entries),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Verify a received sync payload's integrity.
|
|
84
|
+
*/
|
|
85
|
+
export function verifySyncPayload(payload) {
|
|
86
|
+
const computed = computeChecksum(payload.entries);
|
|
87
|
+
if (computed.length !== payload.checksum.length)
|
|
88
|
+
return false;
|
|
89
|
+
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(payload.checksum));
|
|
90
|
+
}
|
|
91
|
+
// ─── Peer Management ─────────────────────────────────────────
|
|
92
|
+
const peers = new Map();
|
|
93
|
+
export function registerPeer(peer) {
|
|
94
|
+
peers.set(peer.id, peer);
|
|
95
|
+
debugLog(`Sync: Registered peer '${peer.name}' (${peer.transport})`);
|
|
96
|
+
}
|
|
97
|
+
export function removePeer(peerId) {
|
|
98
|
+
return peers.delete(peerId);
|
|
99
|
+
}
|
|
100
|
+
export function listPeers() {
|
|
101
|
+
return Array.from(peers.values());
|
|
102
|
+
}
|
|
103
|
+
export function getPeer(peerId) {
|
|
104
|
+
return peers.get(peerId);
|
|
105
|
+
}
|
|
106
|
+
// ─── Sync Execution ──────────────────────────────────────────
|
|
107
|
+
/**
|
|
108
|
+
* Execute an encrypted sync with a peer.
|
|
109
|
+
* In this implementation, we prepare the encrypted payload — the actual
|
|
110
|
+
* transport (stdio/WebSocket) is pluggable.
|
|
111
|
+
*/
|
|
112
|
+
export async function prepareSyncPacket(sourceId, targetPeerId, entries, sharedSecret) {
|
|
113
|
+
const payload = buildSyncPayload(sourceId, targetPeerId, entries);
|
|
114
|
+
const key = deriveKey(sharedSecret);
|
|
115
|
+
const encrypted = encrypt(JSON.stringify(payload), key);
|
|
116
|
+
debugLog(`Sync: Prepared encrypted packet with ${entries.length} entries for peer '${targetPeerId}'`);
|
|
117
|
+
return {
|
|
118
|
+
encrypted,
|
|
119
|
+
metadata: {
|
|
120
|
+
entryCount: entries.length,
|
|
121
|
+
checksum: payload.checksum,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Receive and decrypt a sync packet.
|
|
127
|
+
*/
|
|
128
|
+
export function receiveSyncPacket(encrypted, sharedSecret) {
|
|
129
|
+
const key = deriveKey(sharedSecret);
|
|
130
|
+
const decrypted = decrypt(encrypted, key);
|
|
131
|
+
// Wrap JSON.parse so a malformed payload from a misbehaving peer doesn't
|
|
132
|
+
// crash the receiver with an unhandled SyntaxError. We re-throw with a
|
|
133
|
+
// typed message so callers can distinguish corruption from auth failures.
|
|
134
|
+
let payload;
|
|
135
|
+
try {
|
|
136
|
+
payload = JSON.parse(decrypted);
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
140
|
+
throw new Error(`Sync payload corrupted: invalid JSON (${msg})`);
|
|
141
|
+
}
|
|
142
|
+
if (!verifySyncPayload(payload)) {
|
|
143
|
+
throw new Error("Sync payload integrity check failed — checksum mismatch");
|
|
144
|
+
}
|
|
145
|
+
debugLog(`Sync: Received and verified packet with ${payload.entries.length} entries from '${payload.sourceId}'`);
|
|
146
|
+
return payload;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Resolve conflicts between local and remote entries (last-writer-wins).
|
|
150
|
+
*/
|
|
151
|
+
export function resolveConflicts(local, remote) {
|
|
152
|
+
const localMap = new Map(local.map(e => [e.id, e]));
|
|
153
|
+
let conflictsResolved = 0;
|
|
154
|
+
for (const remoteEntry of remote) {
|
|
155
|
+
const localEntry = localMap.get(remoteEntry.id);
|
|
156
|
+
if (localEntry) {
|
|
157
|
+
// Last-writer-wins
|
|
158
|
+
if (new Date(remoteEntry.createdAt) > new Date(localEntry.createdAt)) {
|
|
159
|
+
localMap.set(remoteEntry.id, remoteEntry);
|
|
160
|
+
conflictsResolved++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
localMap.set(remoteEntry.id, remoteEntry);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
merged: Array.from(localMap.values()),
|
|
169
|
+
conflictsResolved,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
debugLog("v12.3: Encrypted sync module loaded");
|