prism-mcp-server 20.2.1 → 20.2.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/README.md +119 -14
- package/dist/cli.js +221 -33
- package/dist/config.js +12 -6
- package/dist/connect.js +848 -15
- package/dist/dashboard/server.js +9 -14
- package/dist/dashboard/settingsPolicy.js +41 -0
- package/dist/localFirstPolicy.js +20 -0
- package/dist/onboarding/wizard.js +3 -6
- package/dist/server.js +77 -65
- package/dist/session/sessionContext.js +5 -3
- package/dist/skillManifestSync.js +943 -0
- package/dist/storage/configStorage.js +110 -1
- package/dist/storage/index.js +4 -3
- package/dist/storage/inferMetricsLedger.js +72 -14
- package/dist/storage/panelMetricsSpool.js +324 -0
- package/dist/storage/synalux.js +18 -1
- package/dist/tools/__tests__/ledgerHandlers.test.js +13 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/ledgerHandlers.js +468 -53
- package/dist/tools/prismInferHandler.js +171 -12
- package/dist/tools/sessionMemoryDefinitions.js +51 -10
- package/dist/tools/skillRouting.js +39 -7
- package/dist/tools/taskRouterHandler.js +184 -29
- package/dist/utils/inferenceMetrics.js +22 -3
- package/dist/utils/modelPicker.js +3 -3
- package/dist/utils/synaluxJwt.js +2 -1
- package/package.json +3 -1
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { access, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
import { applyManagedSkillManifest, getSetting, refreshConfigStorageCache, } from "./storage/configStorage.js";
|
|
7
|
+
import { REQUIRED_NATIVE_SKILL_NAMES } from "./tools/skillRouting.js";
|
|
8
|
+
import { getSynaluxJwt, invalidateSynaluxJwt } from "./utils/synaluxJwt.js";
|
|
9
|
+
const OWNER = "prism-skill-sync-v1";
|
|
10
|
+
const MARKER = ".prism-managed.json";
|
|
11
|
+
const INDEX = ".prism-managed-skills.json";
|
|
12
|
+
const SHA256 = /^[a-f0-9]{64}$/i;
|
|
13
|
+
const SAFE_NAME = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
14
|
+
const TIERS = new Set(["free", "standard", "advanced", "enterprise"]);
|
|
15
|
+
const CATEGORIES = new Set(["universal", "project", "prompt", "native"]);
|
|
16
|
+
const MAX_SKILLS = 500;
|
|
17
|
+
const MAX_FILES_PER_SKILL = 500;
|
|
18
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
19
|
+
const MAX_MANIFEST_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 8_000;
|
|
21
|
+
const LOCK_WAIT_MS = 10_000;
|
|
22
|
+
const LOCK_POLL_MS = 50;
|
|
23
|
+
const TRANSACTION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
24
|
+
const SIBLING_SKILL_LINK = /\.\.\/([a-z0-9][a-z0-9_-]{0,127})\/SKILL\.md/g;
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
let lastResult = null;
|
|
27
|
+
let lastFinishedAt = 0;
|
|
28
|
+
const SUCCESS_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
function isErrno(error, code) {
|
|
30
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
31
|
+
&& error.code === code;
|
|
32
|
+
}
|
|
33
|
+
function sha256(value) {
|
|
34
|
+
return createHash("sha256").update(value).digest("hex");
|
|
35
|
+
}
|
|
36
|
+
export function computeSkillManifestGeneration(manifest) {
|
|
37
|
+
const canonical = {
|
|
38
|
+
schema_version: 1,
|
|
39
|
+
tier: manifest.tier,
|
|
40
|
+
routing_version: manifest.routing_version,
|
|
41
|
+
skills: manifest.skills.map((skill) => ({
|
|
42
|
+
name: skill.name,
|
|
43
|
+
digest: skill.digest,
|
|
44
|
+
version: skill.version,
|
|
45
|
+
source: skill.source,
|
|
46
|
+
metadata: skill.metadata,
|
|
47
|
+
files: Object.entries(skill.files).map(([path, file]) => ({
|
|
48
|
+
path, digest: file.digest, encoding: file.encoding,
|
|
49
|
+
})),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
return sha256(JSON.stringify(canonical));
|
|
53
|
+
}
|
|
54
|
+
function decodeFile(file) {
|
|
55
|
+
if (file.encoding === "utf8")
|
|
56
|
+
return Buffer.from(file.content, "utf8");
|
|
57
|
+
const compact = file.content.replace(/\s/g, "");
|
|
58
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(compact)) {
|
|
59
|
+
throw new Error("invalid base64 content");
|
|
60
|
+
}
|
|
61
|
+
return Buffer.from(compact, "base64");
|
|
62
|
+
}
|
|
63
|
+
function validateRelativePath(path) {
|
|
64
|
+
if (!path || path.length > 240 || path.includes("\\") || /[\u0000-\u001f<>:"|?*]/.test(path) || isAbsolute(path)) {
|
|
65
|
+
throw new Error(`unsafe skill file path: ${path}`);
|
|
66
|
+
}
|
|
67
|
+
const parts = path.split("/");
|
|
68
|
+
if (parts.some((part) => !part || part === "." || part === ".." || /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)) ||
|
|
69
|
+
path === MARKER || path.startsWith(".prism-")) {
|
|
70
|
+
throw new Error(`unsafe skill file path: ${path}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function validateSkillManifest(payload) {
|
|
74
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
75
|
+
throw new Error("manifest must be an object");
|
|
76
|
+
const value = payload;
|
|
77
|
+
if (value.schema_version !== 1)
|
|
78
|
+
throw new Error("unsupported skill manifest schema_version");
|
|
79
|
+
if (value.generation_algorithm !== "sha256-json-v1")
|
|
80
|
+
throw new Error("unsupported skill manifest generation algorithm");
|
|
81
|
+
if (value.complete !== true)
|
|
82
|
+
throw new Error("skill manifest is not complete");
|
|
83
|
+
if (typeof value.generation !== "string" || !SHA256.test(value.generation))
|
|
84
|
+
throw new Error("invalid manifest generation");
|
|
85
|
+
if (typeof value.tier !== "string" || !TIERS.has(value.tier))
|
|
86
|
+
throw new Error("unknown manifest tier");
|
|
87
|
+
if (!Number.isInteger(value.routing_version) || value.routing_version < 0)
|
|
88
|
+
throw new Error("invalid routing_version");
|
|
89
|
+
if (!Array.isArray(value.skills) || value.skills.length === 0 || value.skills.length > MAX_SKILLS) {
|
|
90
|
+
throw new Error("manifest must contain a bounded, non-empty skill list");
|
|
91
|
+
}
|
|
92
|
+
const names = new Set();
|
|
93
|
+
let totalBytes = 0;
|
|
94
|
+
const skills = value.skills.map((raw, index) => {
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
96
|
+
throw new Error(`invalid skill at index ${index}`);
|
|
97
|
+
const skill = raw;
|
|
98
|
+
if (typeof skill.name !== "string" || !SAFE_NAME.test(skill.name))
|
|
99
|
+
throw new Error(`invalid skill name at index ${index}`);
|
|
100
|
+
const folded = skill.name.toLocaleLowerCase("en-US");
|
|
101
|
+
if (names.has(folded))
|
|
102
|
+
throw new Error(`duplicate skill name: ${skill.name}`);
|
|
103
|
+
names.add(folded);
|
|
104
|
+
if (typeof skill.content !== "string" || !skill.content.trim())
|
|
105
|
+
throw new Error(`empty skill content: ${skill.name}`);
|
|
106
|
+
if (typeof skill.digest !== "string" || !SHA256.test(skill.digest))
|
|
107
|
+
throw new Error(`invalid skill digest: ${skill.name}`);
|
|
108
|
+
if (!Number.isInteger(skill.version) || skill.version < 0)
|
|
109
|
+
throw new Error(`invalid skill version: ${skill.name}`);
|
|
110
|
+
if (skill.source !== "database" && skill.source !== "filesystem")
|
|
111
|
+
throw new Error(`invalid skill source: ${skill.name}`);
|
|
112
|
+
const metadata = skill.metadata;
|
|
113
|
+
if (!metadata || typeof metadata.protected !== "boolean" || !Number.isInteger(metadata.priority) ||
|
|
114
|
+
!Array.isArray(metadata.categories) || metadata.categories.some((category) => typeof category !== "string" || !CATEGORIES.has(category))) {
|
|
115
|
+
throw new Error(`invalid skill metadata: ${skill.name}`);
|
|
116
|
+
}
|
|
117
|
+
if (metadata.minimum_plan !== undefined && (typeof metadata.minimum_plan !== "string" || !TIERS.has(metadata.minimum_plan))) {
|
|
118
|
+
throw new Error(`invalid skill minimum_plan: ${skill.name}`);
|
|
119
|
+
}
|
|
120
|
+
if (metadata.categories.includes("native") &&
|
|
121
|
+
(metadata.minimum_plan === undefined || metadata.minimum_plan === "free")) {
|
|
122
|
+
throw new Error(`native skill requires a paid minimum_plan: ${skill.name}`);
|
|
123
|
+
}
|
|
124
|
+
if (!skill.files || typeof skill.files !== "object" || Array.isArray(skill.files))
|
|
125
|
+
throw new Error(`missing skill files: ${skill.name}`);
|
|
126
|
+
const fileEntries = Object.entries(skill.files);
|
|
127
|
+
if (fileEntries.length === 0 || fileEntries.length > MAX_FILES_PER_SKILL)
|
|
128
|
+
throw new Error(`invalid file count: ${skill.name}`);
|
|
129
|
+
const files = Object.create(null);
|
|
130
|
+
const foldedPaths = new Set();
|
|
131
|
+
for (const [path, rawFile] of fileEntries) {
|
|
132
|
+
validateRelativePath(path);
|
|
133
|
+
const foldedPath = path.toLocaleLowerCase("en-US");
|
|
134
|
+
if (foldedPaths.has(foldedPath))
|
|
135
|
+
throw new Error(`duplicate skill file path: ${skill.name}/${path}`);
|
|
136
|
+
foldedPaths.add(foldedPath);
|
|
137
|
+
if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile))
|
|
138
|
+
throw new Error(`invalid file: ${skill.name}/${path}`);
|
|
139
|
+
const file = rawFile;
|
|
140
|
+
if ((file.encoding !== "utf8" && file.encoding !== "base64") || typeof file.content !== "string" ||
|
|
141
|
+
typeof file.digest !== "string" || !SHA256.test(file.digest))
|
|
142
|
+
throw new Error(`invalid file metadata: ${skill.name}/${path}`);
|
|
143
|
+
const normalized = { content: file.content, digest: file.digest.toLowerCase(), encoding: file.encoding };
|
|
144
|
+
const bytes = decodeFile(normalized);
|
|
145
|
+
if (bytes.length > MAX_FILE_BYTES || sha256(bytes) !== normalized.digest)
|
|
146
|
+
throw new Error(`file digest/size mismatch: ${skill.name}/${path}`);
|
|
147
|
+
totalBytes += bytes.length;
|
|
148
|
+
files[path] = normalized;
|
|
149
|
+
}
|
|
150
|
+
const sortedPaths = fileEntries.map(([path]) => path).sort();
|
|
151
|
+
if (fileEntries.some(([path], fileIndex) => path !== sortedPaths[fileIndex]))
|
|
152
|
+
throw new Error(`skill files are not canonically ordered: ${skill.name}`);
|
|
153
|
+
const entry = files["SKILL.md"];
|
|
154
|
+
if (!entry || entry.encoding !== "utf8" || entry.content !== skill.content || entry.digest !== skill.digest.toLowerCase()) {
|
|
155
|
+
throw new Error(`SKILL.md compatibility fields mismatch: ${skill.name}`);
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
name: skill.name,
|
|
159
|
+
content: skill.content,
|
|
160
|
+
digest: skill.digest.toLowerCase(),
|
|
161
|
+
version: skill.version,
|
|
162
|
+
source: skill.source,
|
|
163
|
+
metadata: {
|
|
164
|
+
protected: metadata.protected,
|
|
165
|
+
priority: metadata.priority,
|
|
166
|
+
categories: metadata.categories,
|
|
167
|
+
...(metadata.minimum_plan === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: { minimum_plan: metadata.minimum_plan }),
|
|
170
|
+
},
|
|
171
|
+
files,
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
if (totalBytes > MAX_MANIFEST_BYTES)
|
|
175
|
+
throw new Error("skill manifest exceeds size limit");
|
|
176
|
+
for (const skill of skills) {
|
|
177
|
+
for (const match of skill.content.matchAll(SIBLING_SKILL_LINK)) {
|
|
178
|
+
const dependency = match[1].toLocaleLowerCase("en-US");
|
|
179
|
+
if (!names.has(dependency)) {
|
|
180
|
+
throw new Error(`unresolved skill dependency: ${skill.name} -> ${match[1]}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const requiredNames = new Set(REQUIRED_NATIVE_SKILL_NAMES);
|
|
185
|
+
for (const required of REQUIRED_NATIVE_SKILL_NAMES) {
|
|
186
|
+
const requiredSkill = skills.find((skill) => skill.name === required);
|
|
187
|
+
if (!requiredSkill)
|
|
188
|
+
throw new Error(`manifest is missing required protected skill: ${required}`);
|
|
189
|
+
if (!requiredSkill.metadata.protected || !requiredSkill.metadata.categories.includes("universal")) {
|
|
190
|
+
throw new Error(`required skill is not protected universal: ${required}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (value.tier === "free" && (skills.length !== requiredNames.size || skills.some((skill) => !requiredNames.has(skill.name)))) {
|
|
194
|
+
throw new Error("free manifest must contain exactly the protected skill floor");
|
|
195
|
+
}
|
|
196
|
+
const normalized = {
|
|
197
|
+
schema_version: 1,
|
|
198
|
+
generation_algorithm: "sha256-json-v1",
|
|
199
|
+
complete: true,
|
|
200
|
+
generation: value.generation.toLowerCase(),
|
|
201
|
+
tier: value.tier,
|
|
202
|
+
routing_version: value.routing_version,
|
|
203
|
+
skills,
|
|
204
|
+
};
|
|
205
|
+
const orderedSkills = [...skills].sort((a, b) => a.metadata.priority - b.metadata.priority || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
206
|
+
if (skills.some((skill, index) => skill !== orderedSkills[index]))
|
|
207
|
+
throw new Error("skills are not canonically ordered");
|
|
208
|
+
if (computeSkillManifestGeneration(normalized) !== normalized.generation)
|
|
209
|
+
throw new Error("manifest generation digest mismatch");
|
|
210
|
+
return normalized;
|
|
211
|
+
}
|
|
212
|
+
async function exists(path) {
|
|
213
|
+
try {
|
|
214
|
+
await access(path, fsConstants.F_OK);
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function readJson(path) {
|
|
222
|
+
try {
|
|
223
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async function listFiles(root, current = root) {
|
|
230
|
+
const files = [];
|
|
231
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
232
|
+
const path = join(current, entry.name);
|
|
233
|
+
if (entry.isSymbolicLink())
|
|
234
|
+
throw new Error(`managed skill contains symlink: ${relative(root, path)}`);
|
|
235
|
+
if (entry.isDirectory())
|
|
236
|
+
files.push(...await listFiles(root, path));
|
|
237
|
+
else if (entry.isFile() && entry.name !== MARKER)
|
|
238
|
+
files.push(relative(root, path).split(sep).join("/"));
|
|
239
|
+
else if (!entry.isFile())
|
|
240
|
+
throw new Error(`managed skill contains unsupported entry: ${relative(root, path)}`);
|
|
241
|
+
}
|
|
242
|
+
return files.sort();
|
|
243
|
+
}
|
|
244
|
+
async function isPristineMarkedSkill(path, generation) {
|
|
245
|
+
const marker = await readJson(join(path, MARKER));
|
|
246
|
+
if (!marker || marker.owner !== OWNER || (generation && marker.generation !== generation) ||
|
|
247
|
+
!marker.files || typeof marker.files !== "object")
|
|
248
|
+
return false;
|
|
249
|
+
try {
|
|
250
|
+
const actualFiles = await listFiles(path);
|
|
251
|
+
const expectedFiles = Object.keys(marker.files).sort();
|
|
252
|
+
if (actualFiles.length !== expectedFiles.length || actualFiles.some((file, i) => file !== expectedFiles[i]))
|
|
253
|
+
return false;
|
|
254
|
+
for (const file of actualFiles) {
|
|
255
|
+
if (!SHA256.test(marker.files[file]) || sha256(await readFile(join(path, file))) !== marker.files[file])
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function isPristineManagedSkill(path, indexed) {
|
|
265
|
+
return indexed && isPristineMarkedSkill(path);
|
|
266
|
+
}
|
|
267
|
+
async function matchesIncomingSkill(path, skill) {
|
|
268
|
+
const marker = await readJson(join(path, MARKER));
|
|
269
|
+
if (!marker || marker.owner !== OWNER)
|
|
270
|
+
return false;
|
|
271
|
+
const incoming = Object.fromEntries(Object.entries(skill.files).map(([file, value]) => [file, value.digest]));
|
|
272
|
+
const actual = Object.keys(marker.files).sort();
|
|
273
|
+
const expected = Object.keys(incoming).sort();
|
|
274
|
+
return actual.length === expected.length && actual.every((file, i) => file === expected[i] && marker.files[file] === incoming[file]);
|
|
275
|
+
}
|
|
276
|
+
async function stageSkill(root, skill, generation) {
|
|
277
|
+
const target = join(root, skill.name);
|
|
278
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
279
|
+
const digests = Object.create(null);
|
|
280
|
+
for (const [file, encoded] of Object.entries(skill.files)) {
|
|
281
|
+
const path = resolve(target, file);
|
|
282
|
+
if (!path.startsWith(`${target}${sep}`))
|
|
283
|
+
throw new Error(`unsafe resolved path: ${file}`);
|
|
284
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
285
|
+
await writeFile(path, decodeFile(encoded), { mode: 0o600 });
|
|
286
|
+
digests[file] = encoded.digest;
|
|
287
|
+
}
|
|
288
|
+
const marker = { owner: OWNER, generation, files: digests };
|
|
289
|
+
await writeFile(join(target, MARKER), `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 });
|
|
290
|
+
return target;
|
|
291
|
+
}
|
|
292
|
+
async function ensureRealDirectory(path) {
|
|
293
|
+
if (!(await exists(path)))
|
|
294
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
295
|
+
const stat = await lstat(path);
|
|
296
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
297
|
+
throw new Error(`managed path must be a real directory: ${path}`);
|
|
298
|
+
}
|
|
299
|
+
async function removeExpiredTransactions(base) {
|
|
300
|
+
const cutoff = Date.now() - TRANSACTION_RETENTION_MS;
|
|
301
|
+
for (const entry of await readdir(base, { withFileTypes: true })) {
|
|
302
|
+
if (!entry.name.startsWith("txn-") || !entry.isDirectory() || entry.isSymbolicLink())
|
|
303
|
+
continue;
|
|
304
|
+
const path = join(base, entry.name);
|
|
305
|
+
const stat = await lstat(path);
|
|
306
|
+
if (stat.mtimeMs < cutoff)
|
|
307
|
+
await rm(path, { recursive: true, force: true });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function quarantineLegacyDiscoveryArtifacts(agentsSkillsDir) {
|
|
311
|
+
const legacy = (await readdir(agentsSkillsDir, { withFileTypes: true })).filter((entry) => entry.name.startsWith(".prism-") && entry.isDirectory() && !entry.isSymbolicLink());
|
|
312
|
+
if (legacy.length === 0)
|
|
313
|
+
return;
|
|
314
|
+
const quarantineBase = join(dirname(agentsSkillsDir), ".prism-skill-quarantine");
|
|
315
|
+
await ensureRealDirectory(quarantineBase);
|
|
316
|
+
for (const entry of legacy) {
|
|
317
|
+
await rename(join(agentsSkillsDir, entry.name), join(quarantineBase, `legacy-${entry.name.slice(1)}-${Date.now()}-${randomUUID()}`));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
async function quarantineManagedSkill(target, name, agentsSkillsDir) {
|
|
321
|
+
const quarantineBase = join(dirname(agentsSkillsDir), ".prism-skill-quarantine");
|
|
322
|
+
await ensureRealDirectory(quarantineBase);
|
|
323
|
+
const quarantined = join(quarantineBase, `${name}-${Date.now()}-${randomUUID()}`);
|
|
324
|
+
await rename(target, quarantined);
|
|
325
|
+
return quarantined;
|
|
326
|
+
}
|
|
327
|
+
async function rollbackNativeOperations(operations, backupRoot) {
|
|
328
|
+
const errors = [];
|
|
329
|
+
await ensureRealDirectory(dirname(backupRoot));
|
|
330
|
+
await ensureRealDirectory(backupRoot);
|
|
331
|
+
for (const operation of [...operations].reverse()) {
|
|
332
|
+
try {
|
|
333
|
+
if (operation.type === "install") {
|
|
334
|
+
if (await exists(operation.target)) {
|
|
335
|
+
await rename(operation.target, join(backupRoot, `${operation.name}.rolled-back-new-${randomUUID()}`));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
else if (operation.type === "update") {
|
|
339
|
+
if (await exists(operation.target)) {
|
|
340
|
+
await rename(operation.target, join(backupRoot, `${operation.name}.failed-update-${randomUUID()}`));
|
|
341
|
+
}
|
|
342
|
+
if (await exists(operation.backup))
|
|
343
|
+
await rename(operation.backup, operation.target);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
// DB entitlement is already committed. A downgraded skill must stay
|
|
347
|
+
// quarantined outside the discovery root even when a later native
|
|
348
|
+
// operation fails; successful cleanup deletes this transaction.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
errors.push(`${operation.type}:${operation.name}:${error instanceof Error ? error.message : String(error)}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return errors;
|
|
357
|
+
}
|
|
358
|
+
async function enforceNativeEntitlements(incomingNames, agentsSkillsDir) {
|
|
359
|
+
await ensureRealDirectory(agentsSkillsDir);
|
|
360
|
+
await quarantineLegacyDiscoveryArtifacts(agentsSkillsDir);
|
|
361
|
+
const incoming = new Set(incomingNames);
|
|
362
|
+
const oldIndex = await readJson(join(agentsSkillsDir, INDEX));
|
|
363
|
+
const candidates = new Set(oldIndex?.owner === OWNER && Array.isArray(oldIndex.skills)
|
|
364
|
+
? oldIndex.skills.filter((name) => typeof name === "string" && SAFE_NAME.test(name))
|
|
365
|
+
: []);
|
|
366
|
+
for (const entry of await readdir(agentsSkillsDir, { withFileTypes: true })) {
|
|
367
|
+
if (!SAFE_NAME.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink())
|
|
368
|
+
continue;
|
|
369
|
+
const marker = await readJson(join(agentsSkillsDir, entry.name, MARKER));
|
|
370
|
+
if (marker?.owner === OWNER)
|
|
371
|
+
candidates.add(entry.name);
|
|
372
|
+
}
|
|
373
|
+
for (const name of candidates) {
|
|
374
|
+
if (incoming.has(name))
|
|
375
|
+
continue;
|
|
376
|
+
const target = join(agentsSkillsDir, name);
|
|
377
|
+
if (!(await exists(target)))
|
|
378
|
+
continue;
|
|
379
|
+
if (await isPristineMarkedSkill(target)) {
|
|
380
|
+
await rm(target, { recursive: true, force: true });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
await quarantineManagedSkill(target, name, agentsSkillsDir);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function resolveNativeSkillsDirs(options) {
|
|
387
|
+
const userHome = options.homeDir ?? homedir();
|
|
388
|
+
const canonical = options.agentsSkillsDir ?? join(userHome, ".agents", "skills");
|
|
389
|
+
let claudeCode = null;
|
|
390
|
+
let cursor = null;
|
|
391
|
+
if (typeof options.claudeCodeSkillsDir === "string") {
|
|
392
|
+
claudeCode = options.claudeCodeSkillsDir;
|
|
393
|
+
}
|
|
394
|
+
else if (options.claudeCodeSkillsDir !== false && options.agentsSkillsDir === undefined) {
|
|
395
|
+
// Claude Code owns ~/.claude and ~/.claude.json. Claude Desktop uses its
|
|
396
|
+
// platform application-support directory, so it does not trigger this
|
|
397
|
+
// filesystem mirror. This is intentionally hook-free: connect creates the
|
|
398
|
+
// MCP registration first, then this regular file sync makes skills visible.
|
|
399
|
+
const claudeHome = join(userHome, ".claude");
|
|
400
|
+
if (await exists(join(userHome, ".claude.json")) || await exists(claudeHome)) {
|
|
401
|
+
claudeCode = join(claudeHome, "skills");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (typeof options.cursorSkillsDir === "string") {
|
|
405
|
+
cursor = options.cursorSkillsDir;
|
|
406
|
+
}
|
|
407
|
+
else if (options.cursorSkillsDir !== false && options.agentsSkillsDir === undefined) {
|
|
408
|
+
// Cursor's native Agent Skills discovery root is ~/.cursor/skills. Keep
|
|
409
|
+
// ~/.agents/skills as the cross-host canonical copy and mirror only when a
|
|
410
|
+
// Cursor home already exists, so installing Prism alone creates no host
|
|
411
|
+
// configuration. This remains a regular, hook-free manifest transaction.
|
|
412
|
+
const cursorHome = join(userHome, ".cursor");
|
|
413
|
+
if (await exists(cursorHome))
|
|
414
|
+
cursor = join(cursorHome, "skills");
|
|
415
|
+
}
|
|
416
|
+
const candidates = [...new Set([canonical, claudeCode, cursor]
|
|
417
|
+
.filter((path) => Boolean(path))
|
|
418
|
+
.map((path) => resolve(path)))];
|
|
419
|
+
const canonicalPath = candidates[0];
|
|
420
|
+
let canonicalTarget = canonicalPath;
|
|
421
|
+
try {
|
|
422
|
+
canonicalTarget = await realpath(canonicalPath);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
if (!isErrno(error, "ENOENT"))
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
const deduplicated = [canonicalPath];
|
|
429
|
+
for (const candidate of candidates.slice(1)) {
|
|
430
|
+
let entry;
|
|
431
|
+
try {
|
|
432
|
+
entry = await lstat(candidate);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
if (isErrno(error, "ENOENT")) {
|
|
436
|
+
deduplicated.push(candidate);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
if (!entry.isSymbolicLink()) {
|
|
442
|
+
deduplicated.push(candidate);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
// Cursor documents ~/.cursor/skills -> ~/.agents/skills as a compatible
|
|
446
|
+
// discovery setup. Treat only that exact target as the canonical root,
|
|
447
|
+
// including a temporarily dangling relative link. Every other symlink
|
|
448
|
+
// fails before fetch or mutation so Prism never writes through a
|
|
449
|
+
// user-owned path.
|
|
450
|
+
let linkTarget = resolve(dirname(candidate), await readlink(candidate));
|
|
451
|
+
try {
|
|
452
|
+
linkTarget = await realpath(candidate);
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
if (!isErrno(error, "ENOENT"))
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
if (linkTarget !== canonicalPath && linkTarget !== canonicalTarget) {
|
|
459
|
+
throw new Error(`native skill mirror is a user-owned symlink; preserved without changes: ${candidate}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return deduplicated;
|
|
463
|
+
}
|
|
464
|
+
function mergeNativeResults(results) {
|
|
465
|
+
const merge = (key) => [...new Set(results.flatMap((result) => result[key]))].sort();
|
|
466
|
+
return {
|
|
467
|
+
installed: merge("installed"),
|
|
468
|
+
updated: merge("updated"),
|
|
469
|
+
pruned: merge("pruned"),
|
|
470
|
+
conflicts: merge("conflicts"),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
async function readCommittedManifestNames() {
|
|
474
|
+
const owner = await getSetting("skill_manifest:owner", "");
|
|
475
|
+
const generation = await getSetting("skill_manifest:generation", "");
|
|
476
|
+
if (owner !== "prism" || !SHA256.test(generation))
|
|
477
|
+
return null;
|
|
478
|
+
try {
|
|
479
|
+
const value = JSON.parse(await getSetting("skill_manifest:names", "[]"));
|
|
480
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
481
|
+
return null;
|
|
482
|
+
const names = value.filter((name) => typeof name === "string" && SAFE_NAME.test(name));
|
|
483
|
+
if (names.length !== value.length || new Set(names).size !== names.length)
|
|
484
|
+
return null;
|
|
485
|
+
return names;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function materializeNative(manifest, agentsSkillsDir, hooks) {
|
|
492
|
+
await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
|
|
493
|
+
const rootStat = await lstat(agentsSkillsDir);
|
|
494
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
495
|
+
throw new Error("native skills root must be a real directory");
|
|
496
|
+
await quarantineLegacyDiscoveryArtifacts(agentsSkillsDir);
|
|
497
|
+
// Transaction content must remain outside the native discovery root: some
|
|
498
|
+
// hosts recursively scan it and would otherwise discover staged/pruned paid
|
|
499
|
+
// SKILL.md files after a crash.
|
|
500
|
+
const transactionBase = join(dirname(agentsSkillsDir), ".prism-skill-transactions");
|
|
501
|
+
await ensureRealDirectory(transactionBase);
|
|
502
|
+
await removeExpiredTransactions(transactionBase);
|
|
503
|
+
const transactionRoot = await mkdtemp(join(transactionBase, "txn-"));
|
|
504
|
+
const stageRoot = join(transactionRoot, "stage");
|
|
505
|
+
const backupRoot = join(transactionRoot, "backup");
|
|
506
|
+
await ensureRealDirectory(stageRoot);
|
|
507
|
+
const oldIndex = await readJson(join(agentsSkillsDir, INDEX));
|
|
508
|
+
const indexed = new Set(oldIndex?.owner === OWNER && Array.isArray(oldIndex.skills)
|
|
509
|
+
? oldIndex.skills.filter((name) => typeof name === "string" && SAFE_NAME.test(name))
|
|
510
|
+
: []);
|
|
511
|
+
const incoming = new Set(manifest.skills.map((skill) => skill.name));
|
|
512
|
+
const nativeEntries = await readdir(agentsSkillsDir, { withFileTypes: true });
|
|
513
|
+
const markerOwned = new Set();
|
|
514
|
+
for (const entry of nativeEntries) {
|
|
515
|
+
if (!SAFE_NAME.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink())
|
|
516
|
+
continue;
|
|
517
|
+
const marker = await readJson(join(agentsSkillsDir, entry.name, MARKER));
|
|
518
|
+
if (marker?.owner === OWNER)
|
|
519
|
+
markerOwned.add(entry.name);
|
|
520
|
+
}
|
|
521
|
+
// The marker is durable transaction provenance. Union it with the index so
|
|
522
|
+
// a process crash between target rename and index rename remains recoverable,
|
|
523
|
+
// including when the next portal generation removes that skill.
|
|
524
|
+
const managedCandidates = new Set([...indexed, ...markerOwned]);
|
|
525
|
+
const existingByFoldedName = new Map(nativeEntries
|
|
526
|
+
.filter((entry) => !entry.name.startsWith(".prism-"))
|
|
527
|
+
.map((entry) => [entry.name.toLocaleLowerCase("en-US"), entry.name]));
|
|
528
|
+
const installed = [];
|
|
529
|
+
const updated = [];
|
|
530
|
+
const pruned = [];
|
|
531
|
+
const conflicts = [];
|
|
532
|
+
const finalManaged = new Set();
|
|
533
|
+
const operations = [];
|
|
534
|
+
let tempIndex = null;
|
|
535
|
+
let retainTransaction = false;
|
|
536
|
+
let indexCommitted = false;
|
|
537
|
+
try {
|
|
538
|
+
// Enforce downgrades first. Once the DB snapshot is committed, obsolete
|
|
539
|
+
// platform content must leave the discovery root before staging or
|
|
540
|
+
// updating any still-entitled skill can fail.
|
|
541
|
+
let pruneFailure = null;
|
|
542
|
+
for (const name of managedCandidates) {
|
|
543
|
+
if (incoming.has(name))
|
|
544
|
+
continue;
|
|
545
|
+
const target = join(agentsSkillsDir, name);
|
|
546
|
+
if (!(await exists(target)))
|
|
547
|
+
continue;
|
|
548
|
+
const stat = await lstat(target);
|
|
549
|
+
const pristine = stat.isDirectory() && !stat.isSymbolicLink() && await isPristineManagedSkill(target, true);
|
|
550
|
+
if (!pristine) {
|
|
551
|
+
// Preserve locally modified managed content, but quarantine it outside
|
|
552
|
+
// host discovery rather than retaining an entitlement bypass.
|
|
553
|
+
conflicts.push(name);
|
|
554
|
+
try {
|
|
555
|
+
await quarantineManagedSkill(target, name, agentsSkillsDir);
|
|
556
|
+
pruned.push(name);
|
|
557
|
+
if (hooks.afterNativePrune)
|
|
558
|
+
await hooks.afterNativePrune(name);
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
pruneFailure ??= error;
|
|
562
|
+
}
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await ensureRealDirectory(backupRoot);
|
|
567
|
+
const backup = join(backupRoot, `${name}-${randomUUID()}`);
|
|
568
|
+
await rename(target, backup);
|
|
569
|
+
operations.push({ type: "prune", name, target, backup });
|
|
570
|
+
pruned.push(name);
|
|
571
|
+
if (hooks.afterNativePrune)
|
|
572
|
+
await hooks.afterNativePrune(name);
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
pruneFailure ??= error;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (pruneFailure)
|
|
579
|
+
throw pruneFailure;
|
|
580
|
+
if (hooks.beforeNativeStage)
|
|
581
|
+
await hooks.beforeNativeStage();
|
|
582
|
+
for (const skill of manifest.skills)
|
|
583
|
+
await stageSkill(stageRoot, skill, manifest.generation);
|
|
584
|
+
for (const skill of manifest.skills) {
|
|
585
|
+
const target = join(agentsSkillsDir, skill.name);
|
|
586
|
+
const staged = join(stageRoot, skill.name);
|
|
587
|
+
const caseAlias = existingByFoldedName.get(skill.name.toLocaleLowerCase("en-US"));
|
|
588
|
+
if (caseAlias && caseAlias !== skill.name) {
|
|
589
|
+
conflicts.push(skill.name);
|
|
590
|
+
if (managedCandidates.has(skill.name))
|
|
591
|
+
finalManaged.add(skill.name);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (!(await exists(target))) {
|
|
595
|
+
await rename(staged, target);
|
|
596
|
+
existingByFoldedName.set(skill.name.toLocaleLowerCase("en-US"), skill.name);
|
|
597
|
+
operations.push({ type: "install", name: skill.name, target });
|
|
598
|
+
installed.push(skill.name);
|
|
599
|
+
finalManaged.add(skill.name);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const stat = await lstat(target);
|
|
603
|
+
const managedSkill = managedCandidates.has(skill.name);
|
|
604
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || !(await isPristineManagedSkill(target, managedSkill))) {
|
|
605
|
+
conflicts.push(skill.name);
|
|
606
|
+
if (managedSkill)
|
|
607
|
+
finalManaged.add(skill.name);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (await matchesIncomingSkill(target, skill)) {
|
|
611
|
+
finalManaged.add(skill.name);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
await ensureRealDirectory(backupRoot);
|
|
615
|
+
const backup = join(backupRoot, skill.name);
|
|
616
|
+
await rename(target, backup);
|
|
617
|
+
try {
|
|
618
|
+
await rename(staged, target);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
await rename(backup, target);
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
operations.push({ type: "update", name: skill.name, target, backup });
|
|
625
|
+
updated.push(skill.name);
|
|
626
|
+
finalManaged.add(skill.name);
|
|
627
|
+
}
|
|
628
|
+
if (hooks.beforeNativeCommit)
|
|
629
|
+
await hooks.beforeNativeCommit();
|
|
630
|
+
const index = { owner: OWNER, generation: manifest.generation, skills: [...finalManaged].sort() };
|
|
631
|
+
tempIndex = join(agentsSkillsDir, `${INDEX}.${randomUUID()}.tmp`);
|
|
632
|
+
await writeFile(tempIndex, `${JSON.stringify(index, null, 2)}\n`, { mode: 0o600 });
|
|
633
|
+
await rename(tempIndex, join(agentsSkillsDir, INDEX));
|
|
634
|
+
tempIndex = null;
|
|
635
|
+
indexCommitted = true;
|
|
636
|
+
if (hooks.beforeNativeCleanup)
|
|
637
|
+
await hooks.beforeNativeCleanup();
|
|
638
|
+
return { installed, updated, pruned, conflicts: [...new Set(conflicts)].sort() };
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
if (tempIndex)
|
|
642
|
+
await rm(tempIndex, { force: true });
|
|
643
|
+
if (indexCommitted) {
|
|
644
|
+
retainTransaction = true;
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
const rollbackErrors = await rollbackNativeOperations(operations, backupRoot);
|
|
648
|
+
retainTransaction = rollbackErrors.length > 0;
|
|
649
|
+
const suffix = rollbackErrors.length > 0 ? `; rollback errors: ${rollbackErrors.join(", ")}` : "";
|
|
650
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${suffix}`);
|
|
651
|
+
}
|
|
652
|
+
finally {
|
|
653
|
+
if (!retainTransaction)
|
|
654
|
+
await rm(transactionRoot, { recursive: true, force: true });
|
|
655
|
+
try {
|
|
656
|
+
if ((await readdir(transactionBase)).length === 0)
|
|
657
|
+
await rm(transactionBase, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
if (error?.code !== "ENOENT" && error?.code !== "ENOTEMPTY")
|
|
661
|
+
throw error;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function runtimeConfig(baseUrl) {
|
|
666
|
+
const configuredUrl = baseUrl || process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim() ||
|
|
667
|
+
(await getSetting("PRISM_SYNALUX_BASE_URL", "")).trim() || (await getSetting("SYNALUX_BASE_URL", "")).trim() || "https://synalux.ai";
|
|
668
|
+
const key = process.env.PRISM_SYNALUX_API_KEY?.trim() || (await getSetting("PRISM_SYNALUX_API_KEY", "")).trim();
|
|
669
|
+
if (!/^https?:\/\//i.test(configuredUrl))
|
|
670
|
+
throw new Error("invalid Synalux base URL");
|
|
671
|
+
process.env.PRISM_SYNALUX_BASE_URL = configuredUrl.replace(/\/+$/, "");
|
|
672
|
+
if (key)
|
|
673
|
+
process.env.PRISM_SYNALUX_API_KEY = key;
|
|
674
|
+
return { baseUrl: process.env.PRISM_SYNALUX_BASE_URL, credentialConfigured: Boolean(key || process.env.PRISM_SKILLS_TOKEN) };
|
|
675
|
+
}
|
|
676
|
+
async function fetchManifest(options) {
|
|
677
|
+
const config = await runtimeConfig(options.baseUrl);
|
|
678
|
+
const credentialConfigured = options.configuredCredential ?? config.credentialConfigured;
|
|
679
|
+
const headers = { Accept: "application/json", "X-Prism-Client": "prism-mcp-skill-sync" };
|
|
680
|
+
const staticToken = process.env.PRISM_SKILLS_TOKEN?.trim();
|
|
681
|
+
let usedJwt = false;
|
|
682
|
+
if (staticToken)
|
|
683
|
+
headers.Authorization = `Bearer ${staticToken}`;
|
|
684
|
+
else if (credentialConfigured) {
|
|
685
|
+
const jwt = await (options.getJwt ?? getSynaluxJwt)();
|
|
686
|
+
if (!jwt)
|
|
687
|
+
throw new Error("configured Synalux credentials could not authenticate");
|
|
688
|
+
headers.Authorization = `Bearer ${jwt}`;
|
|
689
|
+
usedJwt = true;
|
|
690
|
+
}
|
|
691
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
692
|
+
const request = () => fetchImpl(`${config.baseUrl}/api/v1/prism/skill-manifest`, {
|
|
693
|
+
method: "GET", headers, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
694
|
+
});
|
|
695
|
+
let response = await request();
|
|
696
|
+
if (response.status === 401 && usedJwt) {
|
|
697
|
+
(options.invalidateJwt ?? invalidateSynaluxJwt)();
|
|
698
|
+
const fresh = await (options.getJwt ?? getSynaluxJwt)();
|
|
699
|
+
if (!fresh)
|
|
700
|
+
throw new Error("Synalux credential refresh failed");
|
|
701
|
+
headers.Authorization = `Bearer ${fresh}`;
|
|
702
|
+
response = await request();
|
|
703
|
+
}
|
|
704
|
+
if (!response.ok)
|
|
705
|
+
throw new Error(`skill manifest HTTP ${response.status}`);
|
|
706
|
+
const contentLength = Number(response.headers.get("content-length") || "0");
|
|
707
|
+
if (contentLength > MAX_MANIFEST_BYTES)
|
|
708
|
+
throw new Error("skill manifest response exceeds size limit");
|
|
709
|
+
let payload;
|
|
710
|
+
try {
|
|
711
|
+
const reader = response.body?.getReader();
|
|
712
|
+
if (!reader)
|
|
713
|
+
throw new Error("missing response body");
|
|
714
|
+
const chunks = [];
|
|
715
|
+
let received = 0;
|
|
716
|
+
while (true) {
|
|
717
|
+
const { value, done } = await reader.read();
|
|
718
|
+
if (done)
|
|
719
|
+
break;
|
|
720
|
+
received += value.byteLength;
|
|
721
|
+
if (received > MAX_MANIFEST_BYTES) {
|
|
722
|
+
await reader.cancel();
|
|
723
|
+
throw new Error("skill manifest response exceeds size limit");
|
|
724
|
+
}
|
|
725
|
+
chunks.push(value);
|
|
726
|
+
}
|
|
727
|
+
const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), received);
|
|
728
|
+
payload = JSON.parse(bytes.toString("utf8"));
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
if (error instanceof Error && error.message.includes("size limit"))
|
|
732
|
+
throw error;
|
|
733
|
+
throw new Error("skill manifest returned invalid JSON");
|
|
734
|
+
}
|
|
735
|
+
const manifest = validateSkillManifest(payload);
|
|
736
|
+
if (!headers.Authorization && manifest.tier !== "free") {
|
|
737
|
+
throw new Error("unauthenticated skill manifest must be free tier");
|
|
738
|
+
}
|
|
739
|
+
return manifest;
|
|
740
|
+
}
|
|
741
|
+
async function acquireSyncLock(agentsSkillsDir, waitMs = LOCK_WAIT_MS) {
|
|
742
|
+
await mkdir(agentsSkillsDir, { recursive: true, mode: 0o700 });
|
|
743
|
+
const rootStat = await lstat(agentsSkillsDir);
|
|
744
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
745
|
+
throw new Error("native skills root must be a real directory");
|
|
746
|
+
const lockPath = join(agentsSkillsDir, ".prism-sync.lock");
|
|
747
|
+
const deadline = Date.now() + Math.max(0, waitMs);
|
|
748
|
+
const token = randomUUID();
|
|
749
|
+
let handle;
|
|
750
|
+
while (!handle) {
|
|
751
|
+
try {
|
|
752
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
753
|
+
}
|
|
754
|
+
catch (error) {
|
|
755
|
+
if (error?.code !== "EEXIST")
|
|
756
|
+
throw error;
|
|
757
|
+
try {
|
|
758
|
+
const prior = await readJson(lockPath);
|
|
759
|
+
const lockStat = await lstat(lockPath);
|
|
760
|
+
const staleByAge = Date.now() - lockStat.mtimeMs > 10 * 60 * 1000;
|
|
761
|
+
let stale = false;
|
|
762
|
+
if (Number.isInteger(prior?.pid)) {
|
|
763
|
+
try {
|
|
764
|
+
process.kill(prior.pid, 0);
|
|
765
|
+
}
|
|
766
|
+
catch (killError) {
|
|
767
|
+
stale = killError?.code === "ESRCH";
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
stale = staleByAge;
|
|
772
|
+
}
|
|
773
|
+
if (stale) {
|
|
774
|
+
const stalePath = `${lockPath}.stale-${randomUUID()}`;
|
|
775
|
+
await rename(lockPath, stalePath);
|
|
776
|
+
await rm(stalePath, { force: true });
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
catch (inspectError) {
|
|
781
|
+
if (inspectError?.code === "ENOENT")
|
|
782
|
+
continue;
|
|
783
|
+
throw inspectError;
|
|
784
|
+
}
|
|
785
|
+
if (Date.now() >= deadline)
|
|
786
|
+
throw new Error("timed out waiting for another Prism skill sync");
|
|
787
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.min(LOCK_POLL_MS, Math.max(1, deadline - Date.now()))));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const record = {
|
|
791
|
+
owner: OWNER,
|
|
792
|
+
pid: process.pid,
|
|
793
|
+
started_at: new Date().toISOString(),
|
|
794
|
+
token,
|
|
795
|
+
};
|
|
796
|
+
try {
|
|
797
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`);
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
try {
|
|
801
|
+
await handle.close();
|
|
802
|
+
}
|
|
803
|
+
catch { }
|
|
804
|
+
try {
|
|
805
|
+
await rm(lockPath, { force: true });
|
|
806
|
+
}
|
|
807
|
+
catch { }
|
|
808
|
+
throw error;
|
|
809
|
+
}
|
|
810
|
+
return async () => {
|
|
811
|
+
try {
|
|
812
|
+
await handle.close();
|
|
813
|
+
}
|
|
814
|
+
catch (error) {
|
|
815
|
+
console.error(`[Prism Skill Sync] Failed to close native lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
816
|
+
}
|
|
817
|
+
try {
|
|
818
|
+
const current = await readJson(lockPath);
|
|
819
|
+
if (current?.owner === OWNER && current.token === token)
|
|
820
|
+
await rm(lockPath, { force: true });
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
console.error(`[Prism Skill Sync] Failed to remove native lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
export async function synchronizeSkillManifest(options = {}) {
|
|
828
|
+
const empty = { installed: [], updated: [], pruned: [], conflicts: [] };
|
|
829
|
+
let nativeSkillsDirs = [];
|
|
830
|
+
let agentsSkillsDir = "";
|
|
831
|
+
let releaseLock = null;
|
|
832
|
+
let manifest = null;
|
|
833
|
+
let dbApplied = false;
|
|
834
|
+
try {
|
|
835
|
+
nativeSkillsDirs = await resolveNativeSkillsDirs(options);
|
|
836
|
+
agentsSkillsDir = nativeSkillsDirs[0];
|
|
837
|
+
// Fetch only after acquiring the shared lock. A waiter therefore fetches
|
|
838
|
+
// the portal's current generation after the preceding process completes,
|
|
839
|
+
// and DB/native state advance as one serialized pair.
|
|
840
|
+
releaseLock = await acquireSyncLock(agentsSkillsDir, options.lockWaitMs);
|
|
841
|
+
await refreshConfigStorageCache();
|
|
842
|
+
// Recover a hard exit after the DB commit but before native pruning. The
|
|
843
|
+
// already-committed names are sufficient to remove obsolete native skills
|
|
844
|
+
// even when the portal is offline on this restart.
|
|
845
|
+
if (!options.applyManifest) {
|
|
846
|
+
const committedNames = await readCommittedManifestNames();
|
|
847
|
+
if (committedNames) {
|
|
848
|
+
for (const nativeSkillsDir of nativeSkillsDirs) {
|
|
849
|
+
await enforceNativeEntitlements(committedNames, nativeSkillsDir);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
manifest = await fetchManifest(options);
|
|
854
|
+
await (options.applyManifest ?? applyManagedSkillManifest)({
|
|
855
|
+
generation: manifest.generation,
|
|
856
|
+
tier: manifest.tier,
|
|
857
|
+
routingVersion: manifest.routing_version,
|
|
858
|
+
skills: manifest.skills.map(({ name, content, digest }) => ({ name, content, digest })),
|
|
859
|
+
});
|
|
860
|
+
dbApplied = true;
|
|
861
|
+
const nativeResults = [];
|
|
862
|
+
for (const nativeSkillsDir of nativeSkillsDirs) {
|
|
863
|
+
nativeResults.push(await materializeNative(manifest, nativeSkillsDir, options));
|
|
864
|
+
}
|
|
865
|
+
const native = mergeNativeResults(nativeResults);
|
|
866
|
+
const status = native.installed.length || native.updated.length || native.pruned.length ? "applied" : "unchanged";
|
|
867
|
+
return {
|
|
868
|
+
status,
|
|
869
|
+
tier: manifest.tier,
|
|
870
|
+
generation: manifest.generation,
|
|
871
|
+
entitledNames: manifest.skills.map((skill) => skill.name),
|
|
872
|
+
...native,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
catch (error) {
|
|
876
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
877
|
+
if (manifest) {
|
|
878
|
+
const entitledNames = manifest.skills.map((skill) => skill.name);
|
|
879
|
+
let enforcementError = "";
|
|
880
|
+
const enforcementErrors = [];
|
|
881
|
+
for (const nativeSkillsDir of nativeSkillsDirs)
|
|
882
|
+
try {
|
|
883
|
+
// A portal-validated downgrade is authoritative even when the local
|
|
884
|
+
// config transaction fails. Leaving obsolete native directories in
|
|
885
|
+
// discovery would turn a local DB fault into an entitlement bypass.
|
|
886
|
+
await enforceNativeEntitlements(entitledNames, nativeSkillsDir);
|
|
887
|
+
}
|
|
888
|
+
catch (enforcement) {
|
|
889
|
+
enforcementErrors.push(`${nativeSkillsDir}: ${enforcement instanceof Error ? enforcement.message : String(enforcement)}`);
|
|
890
|
+
}
|
|
891
|
+
enforcementError = enforcementErrors.length > 0
|
|
892
|
+
? `; entitlement cleanup failed: ${enforcementErrors.join(", ")}`
|
|
893
|
+
: "";
|
|
894
|
+
return {
|
|
895
|
+
status: "partial", tier: manifest.tier, generation: manifest.generation,
|
|
896
|
+
entitledNames,
|
|
897
|
+
error: `${dbApplied ? "config DB applied; native materialization incomplete" : "authoritative manifest validated; config DB apply incomplete"}: ${detail}${enforcementError}`,
|
|
898
|
+
...empty,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
status: "failed",
|
|
903
|
+
error: detail, ...empty,
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
finally {
|
|
907
|
+
if (releaseLock)
|
|
908
|
+
await releaseLock();
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
/** Single-flight automatic entry point used by startup and session loading. */
|
|
912
|
+
export function triggerSkillManifestSync(options = {}) {
|
|
913
|
+
if (process.env.PRISM_SKILL_SYNC_DISABLED === "true") {
|
|
914
|
+
return Promise.resolve({ status: "disabled", installed: [], updated: [], pruned: [], conflicts: [] });
|
|
915
|
+
}
|
|
916
|
+
if (inFlight)
|
|
917
|
+
return inFlight;
|
|
918
|
+
const run = synchronizeSkillManifest(options);
|
|
919
|
+
inFlight = run.then((result) => {
|
|
920
|
+
lastResult = result;
|
|
921
|
+
lastFinishedAt = Date.now();
|
|
922
|
+
inFlight = null;
|
|
923
|
+
return result;
|
|
924
|
+
}, (error) => {
|
|
925
|
+
inFlight = null;
|
|
926
|
+
throw error;
|
|
927
|
+
});
|
|
928
|
+
return inFlight;
|
|
929
|
+
}
|
|
930
|
+
/** Await the startup run, or start one when a non-server caller loads context. */
|
|
931
|
+
export function awaitSkillManifestSync(options = {}) {
|
|
932
|
+
if (inFlight)
|
|
933
|
+
return inFlight;
|
|
934
|
+
if (!lastResult || lastResult.status === "failed" || lastResult.status === "partial" || Date.now() - lastFinishedAt > SUCCESS_TTL_MS) {
|
|
935
|
+
return triggerSkillManifestSync(options);
|
|
936
|
+
}
|
|
937
|
+
return Promise.resolve(lastResult);
|
|
938
|
+
}
|
|
939
|
+
export function _resetSkillManifestSyncForTest() {
|
|
940
|
+
inFlight = null;
|
|
941
|
+
lastResult = null;
|
|
942
|
+
lastFinishedAt = 0;
|
|
943
|
+
}
|