@hasna/skills 0.10.60 → 0.10.62
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 +41 -7
- package/bin/index.js +855 -455
- package/bin/mcp.js +19044 -36576
- package/bin/migrate.js +10 -4
- package/bin/server.js +10 -4
- package/bin/worker.js +10 -4
- package/dist/cli/commands/runtime-copyfile.d.ts +1 -0
- package/dist/cli/commands/runtime-launcher.d.ts +19 -11
- package/dist/index.js +1638 -1054
- package/dist/lib/agent-adapters.d.ts +2 -2
- package/dist/lib/agent-install-selection.d.ts +11 -0
- package/dist/lib/agent-sumi.d.ts +29 -3
- package/dist/lib/kernel-lock-assets.d.ts +2 -0
- package/dist/native/kernel-lock-acl-darwin +0 -0
- package/dist/runtime-prerequisites.d.ts +9 -0
- package/dist/runtime-prerequisites.js +3089 -0
- package/dist/sdk/index.js +2042 -1458
- package/dist/server/launcher-environment-policy.d.ts +11 -0
- package/docs/runtime-activation.md +23 -0
- package/native/kernel-lock-acl-darwin +0 -0
- package/package.json +9 -3
|
@@ -0,0 +1,3089 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
function __accessProp(key) {
|
|
8
|
+
return this[key];
|
|
9
|
+
}
|
|
10
|
+
var __toESMCache_node;
|
|
11
|
+
var __toESMCache_esm;
|
|
12
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
+
var canCache = mod != null && typeof mod === "object";
|
|
14
|
+
if (canCache) {
|
|
15
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
+
var cached = cache.get(mod);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
}
|
|
20
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
+
for (let key of __getOwnPropNames(mod))
|
|
23
|
+
if (!__hasOwnProp.call(to, key))
|
|
24
|
+
__defProp(to, key, {
|
|
25
|
+
get: __accessProp.bind(mod, key),
|
|
26
|
+
enumerable: true
|
|
27
|
+
});
|
|
28
|
+
if (canCache)
|
|
29
|
+
cache.set(mod, to);
|
|
30
|
+
return to;
|
|
31
|
+
};
|
|
32
|
+
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
33
|
+
var __returnValue = (v) => v;
|
|
34
|
+
function __exportSetter(name, newValue) {
|
|
35
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
36
|
+
}
|
|
37
|
+
var __export = (target, all) => {
|
|
38
|
+
for (var name in all)
|
|
39
|
+
__defProp(target, name, {
|
|
40
|
+
get: all[name],
|
|
41
|
+
enumerable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
set: __exportSetter.bind(all, name)
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
47
|
+
var __require = import.meta.require;
|
|
48
|
+
|
|
49
|
+
// src/runtime-prerequisites.ts
|
|
50
|
+
import { isAbsolute as isAbsolute5 } from "path";
|
|
51
|
+
// package.json
|
|
52
|
+
var version = "0.10.62";
|
|
53
|
+
var package_default = {
|
|
54
|
+
name: "@hasna/skills",
|
|
55
|
+
version,
|
|
56
|
+
description: "Skills library for AI coding agents",
|
|
57
|
+
type: "module",
|
|
58
|
+
bin: {
|
|
59
|
+
skills: "bin/index.js",
|
|
60
|
+
"skills-mcp": "bin/mcp.js",
|
|
61
|
+
"skills-serve": "bin/server.js",
|
|
62
|
+
"skills-server": "bin/server.js",
|
|
63
|
+
"skills-worker": "bin/worker.js",
|
|
64
|
+
"skills-maintenance": "bin/maintenance.js",
|
|
65
|
+
"skills-migrate": "bin/migrate.js"
|
|
66
|
+
},
|
|
67
|
+
exports: {
|
|
68
|
+
".": {
|
|
69
|
+
import: "./dist/index.js",
|
|
70
|
+
types: "./dist/index.d.ts"
|
|
71
|
+
},
|
|
72
|
+
"./storage": {
|
|
73
|
+
import: "./dist/storage.js",
|
|
74
|
+
types: "./dist/storage.d.ts"
|
|
75
|
+
},
|
|
76
|
+
"./sdk": {
|
|
77
|
+
import: "./dist/sdk/index.js",
|
|
78
|
+
types: "./dist/sdk/index.d.ts"
|
|
79
|
+
},
|
|
80
|
+
"./admin-contract": {
|
|
81
|
+
import: "./dist/admin-contract.js",
|
|
82
|
+
types: "./dist/admin-contract.d.ts"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
files: [
|
|
86
|
+
"dist/",
|
|
87
|
+
"!dist/**/*.test.d.ts",
|
|
88
|
+
"!dist/**/*.fixture.d.ts",
|
|
89
|
+
"!dist/test-preload.d.ts",
|
|
90
|
+
"!dist/platform",
|
|
91
|
+
"bin/",
|
|
92
|
+
"native/kernel-lock-acl-darwin",
|
|
93
|
+
"migrations/",
|
|
94
|
+
"docs/skill-standard.md",
|
|
95
|
+
"docs/plugin-admission.md",
|
|
96
|
+
"docs/runtime-activation.md",
|
|
97
|
+
"schemas/",
|
|
98
|
+
"LICENSE",
|
|
99
|
+
"README.md",
|
|
100
|
+
"!dist/**/*test-utils.d.ts",
|
|
101
|
+
"!dist/**/*fixtures.d.ts"
|
|
102
|
+
],
|
|
103
|
+
main: "./dist/index.js",
|
|
104
|
+
types: "./dist/index.d.ts",
|
|
105
|
+
scripts: {
|
|
106
|
+
clean: "rm -rf bin/ dist/",
|
|
107
|
+
build: "bun run clean && bun build --external @hasna/contracts/kernel-lock ./src/cli/index.tsx --outdir ./bin --target bun && bun build --external @modelcontextprotocol/sdk ./src/mcp/index.ts --outfile ./bin/mcp.js --target bun && bun build --external @hasna/contracts/kernel-lock ./src/server/index.ts --outfile ./bin/server.js --target bun && bun build --external @hasna/contracts/kernel-lock ./src/server/worker.ts --outfile ./bin/worker.js --target bun && bun build --external @hasna/contracts/kernel-lock ./src/server/migrate.ts --outfile ./bin/migrate.js --target bun && bun build --external @hasna/contracts/kernel-lock ./src/server/maintenance.ts --outfile ./bin/maintenance.js --target bun && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts ./src/runtime-prerequisites.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist && bun scripts/stage-kernel-lock-assets.ts",
|
|
108
|
+
"build:js": "rm -rf dist && bun build ./src/index.ts ./src/storage.ts ./src/sdk/index.ts ./src/admin-contract.ts ./src/runtime-prerequisites.ts --outdir ./dist --target bun && tsc --emitDeclarationOnly --declaration --outDir dist && bun scripts/stage-kernel-lock-assets.ts",
|
|
109
|
+
test: "bun test --timeout 30000",
|
|
110
|
+
dev: "bun run ./src/cli/index.tsx",
|
|
111
|
+
"dev:watch": "bun --watch run ./src/cli/index.tsx",
|
|
112
|
+
"dev:mcp": "bun --watch run ./src/mcp/index.ts",
|
|
113
|
+
"dev:server": "bun --watch run ./src/server/index.ts",
|
|
114
|
+
"dev:worker": "bun --watch run ./src/server/worker.ts",
|
|
115
|
+
"docker:check": "bash scripts/docker-build-check.sh",
|
|
116
|
+
migrate: "bun run ./src/server/migrate.ts",
|
|
117
|
+
typecheck: "tsc --noEmit",
|
|
118
|
+
"verify:release": "bun run scripts/release-guard.ts",
|
|
119
|
+
"verify:release-review": "bun run scripts/verify-release-review.ts",
|
|
120
|
+
"verify:consumer-types": "bun run scripts/consumer-types.ts",
|
|
121
|
+
prepack: "bun run verify:generated && bun run verify:release && bun run verify:consumer-types",
|
|
122
|
+
prepublishOnly: "bun run verify:producer && bun run typecheck && bun run test",
|
|
123
|
+
"verify:producer": "bun run scripts/release-dependencies.ts",
|
|
124
|
+
"verify:generated": "HASNA_SKILLS_RELEASE_ARTIFACT_TEST=1 bun test src/lib/release-artifacts.test.ts",
|
|
125
|
+
"verify:consumer-credentials": "bun run scripts/consumer-credentials.ts"
|
|
126
|
+
},
|
|
127
|
+
keywords: [
|
|
128
|
+
"skills",
|
|
129
|
+
"ai",
|
|
130
|
+
"agent",
|
|
131
|
+
"cli",
|
|
132
|
+
"typescript",
|
|
133
|
+
"bun",
|
|
134
|
+
"claude",
|
|
135
|
+
"codex",
|
|
136
|
+
"gemini",
|
|
137
|
+
"mcp",
|
|
138
|
+
"model-context-protocol",
|
|
139
|
+
"open-source",
|
|
140
|
+
"skills-library",
|
|
141
|
+
"automation"
|
|
142
|
+
],
|
|
143
|
+
author: "Hasna",
|
|
144
|
+
license: "Apache-2.0",
|
|
145
|
+
devDependencies: {
|
|
146
|
+
"@types/bun": "1.3.14",
|
|
147
|
+
"@types/node": "25.2.3",
|
|
148
|
+
"@types/react": "^18.2.0",
|
|
149
|
+
"bun-types": "1.3.14",
|
|
150
|
+
"react-devtools-core": "^7.0.1",
|
|
151
|
+
typescript: "^5",
|
|
152
|
+
yaml: "^2.9.0"
|
|
153
|
+
},
|
|
154
|
+
dependencies: {
|
|
155
|
+
"@aws-sdk/client-ecs": "^3.1079.0",
|
|
156
|
+
"@aws-sdk/client-s3": "^3.1079.0",
|
|
157
|
+
"@hasna/events": "0.1.18",
|
|
158
|
+
"@hasna/secrets": "0.4.2",
|
|
159
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
160
|
+
chalk: "^5.3.0",
|
|
161
|
+
commander: "^12.1.0",
|
|
162
|
+
ink: "^5.0.1",
|
|
163
|
+
"ink-select-input": "^6.0.0",
|
|
164
|
+
"ink-spinner": "^5.0.0",
|
|
165
|
+
"ink-text-input": "^6.0.0",
|
|
166
|
+
react: "^18.2.0",
|
|
167
|
+
zod: "^4.3.6",
|
|
168
|
+
"@hasna/contracts": "1.3.5"
|
|
169
|
+
},
|
|
170
|
+
engines: {
|
|
171
|
+
bun: ">=1.0.0"
|
|
172
|
+
},
|
|
173
|
+
publishConfig: {
|
|
174
|
+
registry: "https://registry.npmjs.org",
|
|
175
|
+
access: "public"
|
|
176
|
+
},
|
|
177
|
+
repository: {
|
|
178
|
+
type: "git",
|
|
179
|
+
url: "https://github.com/hasna/skills.git",
|
|
180
|
+
directory: "apps/skills"
|
|
181
|
+
},
|
|
182
|
+
homepage: "https://github.com/hasna/skills/tree/main/apps/skills",
|
|
183
|
+
bugs: {
|
|
184
|
+
url: "https://github.com/hasna/skills/issues"
|
|
185
|
+
},
|
|
186
|
+
skillsRuntimePrerequisites: {
|
|
187
|
+
version: 1,
|
|
188
|
+
entry: "dist/runtime-prerequisites.js"
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// src/lib/agent-install-selection.ts
|
|
193
|
+
import { lstatSync as lstatSync4 } from "fs";
|
|
194
|
+
import { dirname as dirname5, join as join7, resolve as resolve6 } from "path";
|
|
195
|
+
import { homedir as homedir2 } from "os";
|
|
196
|
+
|
|
197
|
+
// src/lib/agent-adapters.ts
|
|
198
|
+
var INTEGRATION_AGENTS = ["claude", "codex", "gemini", "opencode", "cursor", "hermes", "sumi"];
|
|
199
|
+
function renderAgentHookCommand(command, agent, profileId, event) {
|
|
200
|
+
if (typeof profileId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(profileId) || profileId.includes(".."))
|
|
201
|
+
throw new Error("Invalid selection profile id");
|
|
202
|
+
return `'${command.replace(/'/g, `'\\''`)}' hook user-prompt --agent ${agent} --selection-profile ${profileId} --event ${event}`;
|
|
203
|
+
}
|
|
204
|
+
var AGENT_ADAPTERS = {
|
|
205
|
+
claude: { root: ".claude/skills", config: ".claude/settings.json", events: ["UserPromptSubmit", "SessionStart", "SubagentStart"], promptContext: true },
|
|
206
|
+
codex: { root: ".codex/skills", config: ".codex/hooks.json", events: ["UserPromptSubmit", "SessionStart", "SubagentStart"], promptContext: true },
|
|
207
|
+
gemini: { root: ".gemini/skills", config: ".gemini/settings.json", events: ["BeforeAgent", "SessionStart"], promptContext: true },
|
|
208
|
+
opencode: { root: ".config/opencode/skills", config: ".config/opencode/opencode.json", events: ["chat.message"], promptContext: true },
|
|
209
|
+
sumi: { root: null, config: null, events: ["prompt", "context"], promptContext: true },
|
|
210
|
+
hermes: { root: ".hermes/skills", config: ".hermes/config.yaml", events: ["pre_llm_call", "pre_tool_call"], promptContext: true, promptFailureMode: "open", toolFailureMode: "supervised-child-errors-block", requiresNativeTrust: true },
|
|
211
|
+
cursor: { root: ".cursor/skills", config: ".cursor/hooks.json", events: ["beforeSubmitPrompt", "sessionStart"], promptContext: false }
|
|
212
|
+
};
|
|
213
|
+
function renderOpenCodePlugin(command, profileId) {
|
|
214
|
+
return `// Managed by @hasna/skills. Regenerate with skills hook install --agent opencode.
|
|
215
|
+
import { spawn } from "node:child_process";
|
|
216
|
+
import { randomUUID } from "node:crypto";
|
|
217
|
+
const command = ${JSON.stringify(command)};
|
|
218
|
+
const profile = ${JSON.stringify(profileId)};
|
|
219
|
+
function context(input) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const child = spawn(command, ["hook", "user-prompt", "--agent", "opencode", "--selection-profile", profile], { cwd: input.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
222
|
+
const chunks = []; let bytes = 0, failure;
|
|
223
|
+
const timer = setTimeout(() => { failure = new Error("Skills prompt hook timed out"); child.kill("SIGKILL"); }, 15000);
|
|
224
|
+
child.stdout.on("data", chunk => { bytes += chunk.length; if (bytes > 1048576) { failure = new Error("Skills prompt hook output exceeded its limit"); child.kill("SIGKILL"); } else chunks.push(chunk); });
|
|
225
|
+
child.stderr.on("data", () => {});
|
|
226
|
+
child.stdin.on("error", () => {});
|
|
227
|
+
child.on("error", error => { clearTimeout(timer); reject(error); });
|
|
228
|
+
child.on("close", code => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
if (failure || code !== 0) return reject(failure ?? new Error("Skills prompt hook failed"));
|
|
231
|
+
try {
|
|
232
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
233
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error("Invalid Skills hook response");
|
|
234
|
+
if (result.decision === "block" || result.continue === false) throw new Error(result.reason ?? result.stopReason ?? "Skills prompt hook refused");
|
|
235
|
+
if (Object.keys(result).length && (!result.hookSpecificOutput || result.hookSpecificOutput.hookEventName !== input.hook_event_name)) throw new Error("Invalid Skills hook response");
|
|
236
|
+
const text = result.hookSpecificOutput?.additionalContext ?? "";
|
|
237
|
+
if (typeof text !== "string") throw new Error("Invalid Skills prompt context");
|
|
238
|
+
resolve(text);
|
|
239
|
+
} catch (error) { reject(error); }
|
|
240
|
+
});
|
|
241
|
+
child.stdin.end(JSON.stringify(input));
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
export default async ({ directory }) => {
|
|
245
|
+
const sessions = new Set();
|
|
246
|
+
return {
|
|
247
|
+
"chat.message": async (input, output) => {
|
|
248
|
+
const parts = output.parts.filter(part => part.type === "text" && typeof part.text === "string");
|
|
249
|
+
const first = !sessions.has(input.sessionID);
|
|
250
|
+
const text = await context({ hook_event_name: first ? "SessionStart" : "UserPromptSubmit", cwd: directory, session_id: input.sessionID, prompt: parts.map(part => part.text).join("\\n") });
|
|
251
|
+
if (text && parts.length) parts[0].text += "\\n\\n" + text;
|
|
252
|
+
else if (text) output.parts.push({ id: "prt_" + randomUUID().replaceAll("-", ""), sessionID: input.sessionID, messageID: output.message.id, type: "text", text, synthetic: true });
|
|
253
|
+
if (sessions.size >= 1000) sessions.clear();
|
|
254
|
+
sessions.add(input.sessionID);
|
|
255
|
+
},
|
|
256
|
+
"tool.execute.before": async (input, output) => {
|
|
257
|
+
if (input.tool === "skill" && output.args?.name !== "skills-cli") throw new Error("Load instructions through the Skills CLI bridge");
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/lib/native-discovery-roots.ts
|
|
265
|
+
var NATIVE_SKILL_ROOTS = [
|
|
266
|
+
["claude", ".claude/skills"],
|
|
267
|
+
["codex", ".codex/skills"],
|
|
268
|
+
["codex", ".agents/skills"],
|
|
269
|
+
["gemini", ".gemini/skills"],
|
|
270
|
+
["sumi", ".sumi/skills"],
|
|
271
|
+
["sumi", ".sumi/skill"],
|
|
272
|
+
["codewith", ".codewith/skills"],
|
|
273
|
+
["opencode", ".config/opencode/skills"],
|
|
274
|
+
["opencode", ".opencode/skills"],
|
|
275
|
+
["cursor", ".cursor/skills"],
|
|
276
|
+
["hermes", ".hermes/skills"],
|
|
277
|
+
["windsurf", ".windsurf/skills"],
|
|
278
|
+
["pi", ".pi/agent/skills"],
|
|
279
|
+
["amp", ".amp/skills"],
|
|
280
|
+
["cline", ".cline/skills"],
|
|
281
|
+
["roo", ".roo/skills"],
|
|
282
|
+
["copilot", ".github/skills"]
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
// src/lib/managed-policy.ts
|
|
286
|
+
import { closeSync as closeSync3, constants as constants3, fstatSync as fstatSync3, lstatSync as lstatSync3, openSync as openSync3, readSync as readSync3 } from "fs";
|
|
287
|
+
import { join as join6 } from "path";
|
|
288
|
+
|
|
289
|
+
// src/lib/config.ts
|
|
290
|
+
import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
291
|
+
import { join as join2, dirname } from "path";
|
|
292
|
+
|
|
293
|
+
// src/lib/retired-settings.ts
|
|
294
|
+
var RETIRED_ENV_SUFFIXES = ["_STORAGE_MODE", "_DEPLOYMENT_MODE", "_CLOUD_MODE"];
|
|
295
|
+
var RETIRED_CONFIG_KEYS = {
|
|
296
|
+
mode: "a configured API origin",
|
|
297
|
+
apiUrl: "skills setup --api-url <origin>"
|
|
298
|
+
};
|
|
299
|
+
var RETIRED_CONFIG_KEY_REASONS = {
|
|
300
|
+
mode: "Deployment modes were removed: a Skills client either resolves a credential " + "or it does not, and that is the whole of it.",
|
|
301
|
+
apiUrl: "The service address is no longer kept in this app's config file: it is read from " + "HASNA_SKILLS_API_URL, then the macOS Keychain item hasna.credentials.skills.api-url, " + "then ~/.hasna/skills/config/credentials, then the fleet gateway."
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
class RetiredSettingError extends Error {
|
|
305
|
+
code = "RETIRED_SETTING";
|
|
306
|
+
setting;
|
|
307
|
+
constructor(setting, message) {
|
|
308
|
+
super(message);
|
|
309
|
+
this.name = "RetiredSettingError";
|
|
310
|
+
this.setting = setting;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function isRetiredModeEnvVar(name, app) {
|
|
314
|
+
const upper = name.toUpperCase();
|
|
315
|
+
if (!upper.includes(app.toUpperCase()))
|
|
316
|
+
return false;
|
|
317
|
+
return RETIRED_ENV_SUFFIXES.some((suffix) => upper.endsWith(suffix));
|
|
318
|
+
}
|
|
319
|
+
function findRetiredModeEnvVars(env, app) {
|
|
320
|
+
const found = [];
|
|
321
|
+
for (const [name, value] of Object.entries(env)) {
|
|
322
|
+
if (value === undefined || value === "")
|
|
323
|
+
continue;
|
|
324
|
+
if (isRetiredModeEnvVar(name, app))
|
|
325
|
+
found.push({ name, value });
|
|
326
|
+
}
|
|
327
|
+
return found.sort((a, b) => a.name.localeCompare(b.name));
|
|
328
|
+
}
|
|
329
|
+
function assertNoRetiredModeEnvVars(env, options) {
|
|
330
|
+
const found = findRetiredModeEnvVars(env, options.app);
|
|
331
|
+
if (found.length === 0)
|
|
332
|
+
return;
|
|
333
|
+
const names = found.map((entry) => entry.name);
|
|
334
|
+
throw new RetiredSettingError(names[0], `${names.join(", ")} ${names.length === 1 ? "is" : "are"} no longer read. ` + "Deployment modes were removed: where a server keeps its data is decided by the " + `database it is given, not by a declared label. Set ${options.replacement} to a ` + "postgres:// URL to use PostgreSQL, or leave it unset for the on-box SQLite database. " + `Then unset ${names.join(" and ")}. ` + "Refused rather than ignored, because a discarded setting looks exactly like a " + "working one until something needs the data.");
|
|
335
|
+
}
|
|
336
|
+
function assertNoRetiredConfigKeys(config, source) {
|
|
337
|
+
for (const [key, replacement] of Object.entries(RETIRED_CONFIG_KEYS)) {
|
|
338
|
+
if (!(key in config))
|
|
339
|
+
continue;
|
|
340
|
+
throw new RetiredSettingError(key, `${source}: "${key}" is no longer a configuration key. ` + `${RETIRED_CONFIG_KEY_REASONS[key] ?? ""} ` + `Use ${replacement} instead, and remove the old key with: skills config unset ${key}. ` + "Refused rather than ignored, because silently dropping it would leave an " + "operator believing they had pointed this install at a server.");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/lib/app-home.ts
|
|
345
|
+
import { existsSync } from "fs";
|
|
346
|
+
import { homedir } from "os";
|
|
347
|
+
import { join, resolve } from "path";
|
|
348
|
+
import { homedir as pathsResolverHomedir } from "os";
|
|
349
|
+
import { join as pathsResolverJoin } from "path";
|
|
350
|
+
var PATHS_RESOLVER_DATA_ENV = "HASNA_DATA_HOME";
|
|
351
|
+
var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
352
|
+
function pathsResolverAssertApp(app) {
|
|
353
|
+
if (typeof app !== "string" || app.length === 0) {
|
|
354
|
+
throw new TypeError("paths: app must be a non-empty string");
|
|
355
|
+
}
|
|
356
|
+
if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
|
|
357
|
+
throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function pathsResolverDataBaseDir(options) {
|
|
361
|
+
const env = options.env ?? process.env;
|
|
362
|
+
const override = env[PATHS_RESOLVER_DATA_ENV];
|
|
363
|
+
if (typeof override === "string" && override.length > 0)
|
|
364
|
+
return override;
|
|
365
|
+
const home = options.home ?? pathsResolverHomedir();
|
|
366
|
+
const platform = options.platform ?? process.platform;
|
|
367
|
+
return platform === "darwin" ? pathsResolverJoin(home, "Library", "Application Support", "Hasna") : pathsResolverJoin(home, ".local", "share", "hasna");
|
|
368
|
+
}
|
|
369
|
+
function dataDir(options) {
|
|
370
|
+
pathsResolverAssertApp(options.app);
|
|
371
|
+
const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
|
|
372
|
+
return pathsResolverJoin(pathsResolverDataBaseDir(options), appSegment);
|
|
373
|
+
}
|
|
374
|
+
var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
|
|
375
|
+
var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
|
|
376
|
+
var SKILLS_HOME_ENV = "SKILLS_HOME";
|
|
377
|
+
var DEFAULT_SQLITE_FILENAME = "server.db";
|
|
378
|
+
var GLOBAL_CONFIG_FILENAME = "config.json";
|
|
379
|
+
function effectiveHome() {
|
|
380
|
+
return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
|
|
381
|
+
}
|
|
382
|
+
function legacyDataRoot() {
|
|
383
|
+
return join(effectiveHome(), ".hasna", "skills");
|
|
384
|
+
}
|
|
385
|
+
function resolverDataRoot(home = effectiveHome(), env) {
|
|
386
|
+
return dataDir({ app: "skills", home, env });
|
|
387
|
+
}
|
|
388
|
+
function adoptResolverDataRoot(resolved, env = process.env) {
|
|
389
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
390
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
391
|
+
return true;
|
|
392
|
+
return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
|
|
393
|
+
}
|
|
394
|
+
function exactDataRoot() {
|
|
395
|
+
for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
|
|
396
|
+
const dir = process.env[key]?.trim();
|
|
397
|
+
if (dir)
|
|
398
|
+
return resolve(dir);
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
function getDataRoot() {
|
|
403
|
+
const exact = exactDataRoot();
|
|
404
|
+
if (exact)
|
|
405
|
+
return exact;
|
|
406
|
+
const resolved = resolverDataRoot();
|
|
407
|
+
return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(legacyDataRoot());
|
|
408
|
+
}
|
|
409
|
+
function skillsDataRootForHome(home) {
|
|
410
|
+
const isOwnHome = resolve(home) === resolve(effectiveHome()) || resolve(home) === resolve(homedir());
|
|
411
|
+
if (isOwnHome) {
|
|
412
|
+
const exact = exactDataRoot();
|
|
413
|
+
if (exact)
|
|
414
|
+
return exact;
|
|
415
|
+
const resolved2 = resolverDataRoot(home);
|
|
416
|
+
return adoptResolverDataRoot(resolved2) ? resolve(resolved2) : resolve(join(home, ".hasna", "skills"));
|
|
417
|
+
}
|
|
418
|
+
const resolved = resolverDataRoot(home, {});
|
|
419
|
+
return adoptResolverDataRoot(resolved, {}) ? resolve(resolved) : resolve(join(home, ".hasna", "skills"));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/lib/config.ts
|
|
423
|
+
var ENUM_KEYS = {
|
|
424
|
+
defaultAgent: ["claude", "codex", "gemini", "pi", "opencode", "all"],
|
|
425
|
+
defaultScope: ["global", "project"],
|
|
426
|
+
format: ["compact", "json", "csv"]
|
|
427
|
+
};
|
|
428
|
+
var STRING_KEYS = ["extensionsDir"];
|
|
429
|
+
function validKeys() {
|
|
430
|
+
return [...Object.keys(ENUM_KEYS), ...STRING_KEYS];
|
|
431
|
+
}
|
|
432
|
+
function allowedValues(key) {
|
|
433
|
+
return ENUM_KEYS[key];
|
|
434
|
+
}
|
|
435
|
+
function normalizeConfigValue(key, value) {
|
|
436
|
+
if (typeof value !== "string")
|
|
437
|
+
return;
|
|
438
|
+
const allowed = allowedValues(key);
|
|
439
|
+
if (allowed)
|
|
440
|
+
return allowed.includes(value) ? value : undefined;
|
|
441
|
+
if (key === "extensionsDir")
|
|
442
|
+
return value.trim() ? value : undefined;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
var INSTALLED_SKILLS_DIRNAME = "installed";
|
|
446
|
+
var SKILLS_CACHE_DIRNAME = "skills";
|
|
447
|
+
var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
|
|
448
|
+
function isOwnerLayoutMigrated(appDir) {
|
|
449
|
+
return existsSync2(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
|
|
450
|
+
}
|
|
451
|
+
function getDataDir() {
|
|
452
|
+
const root = getDataRoot();
|
|
453
|
+
try {
|
|
454
|
+
mkdirSync(root, { recursive: true });
|
|
455
|
+
} catch {}
|
|
456
|
+
return root;
|
|
457
|
+
}
|
|
458
|
+
function getDataDirReadOnly() {
|
|
459
|
+
return getDataRoot();
|
|
460
|
+
}
|
|
461
|
+
function getConfigPath(scope) {
|
|
462
|
+
if (scope === "global") {
|
|
463
|
+
return join2(getDataDir(), "config.json");
|
|
464
|
+
}
|
|
465
|
+
return join2(process.cwd(), "skills.config.json");
|
|
466
|
+
}
|
|
467
|
+
function readConfigFile(path) {
|
|
468
|
+
if (!existsSync2(path))
|
|
469
|
+
return {};
|
|
470
|
+
let parsed;
|
|
471
|
+
try {
|
|
472
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
473
|
+
} catch {
|
|
474
|
+
return {};
|
|
475
|
+
}
|
|
476
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
477
|
+
return {};
|
|
478
|
+
assertNoRetiredConfigKeys(parsed, path);
|
|
479
|
+
const config = {};
|
|
480
|
+
for (const key of validKeys()) {
|
|
481
|
+
const value = normalizeConfigValue(key, parsed[key]);
|
|
482
|
+
if (value !== undefined)
|
|
483
|
+
config[key] = value;
|
|
484
|
+
}
|
|
485
|
+
return config;
|
|
486
|
+
}
|
|
487
|
+
function loadConfig() {
|
|
488
|
+
const globalConfig = readConfigFile(getConfigPath("global"));
|
|
489
|
+
const projectConfig = readConfigFile(getConfigPath("project"));
|
|
490
|
+
return { ...globalConfig, ...projectConfig };
|
|
491
|
+
}
|
|
492
|
+
function saveConfig(key, value, scope = "project") {
|
|
493
|
+
assertNoRetiredConfigKeys({ [key]: value }, `config set ${key}`);
|
|
494
|
+
if (!validKeys().includes(key)) {
|
|
495
|
+
throw new Error(`Unknown config key: ${key}. Valid keys: ${validKeys().join(", ")}`);
|
|
496
|
+
}
|
|
497
|
+
const normalized = normalizeConfigValue(key, value);
|
|
498
|
+
if (normalized === undefined) {
|
|
499
|
+
const allowed = allowedValues(key);
|
|
500
|
+
throw new Error(allowed ? `Invalid value '${value}' for ${key}. Allowed: ${allowed.join(", ")}` : `Invalid value '${value}' for ${key}. Expected a non-empty path`);
|
|
501
|
+
}
|
|
502
|
+
const filePath = getConfigPath(scope);
|
|
503
|
+
let existing = {};
|
|
504
|
+
if (existsSync2(filePath)) {
|
|
505
|
+
try {
|
|
506
|
+
existing = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
507
|
+
if (typeof existing !== "object" || existing === null || Array.isArray(existing)) {
|
|
508
|
+
existing = {};
|
|
509
|
+
}
|
|
510
|
+
} catch {
|
|
511
|
+
existing = {};
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
const dir = dirname(filePath);
|
|
515
|
+
if (!existsSync2(dir)) {
|
|
516
|
+
mkdirSync(dir, { recursive: true });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
assertNoRetiredConfigKeys(existing, filePath);
|
|
520
|
+
existing[key] = normalized;
|
|
521
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2) + `
|
|
522
|
+
`);
|
|
523
|
+
}
|
|
524
|
+
function unsetConfig(key, scope = "project") {
|
|
525
|
+
if (!validKeys().includes(key) && !(key in RETIRED_CONFIG_KEYS)) {
|
|
526
|
+
throw new Error(`Unknown config key: ${key}. Valid keys: ${validKeys().join(", ")}`);
|
|
527
|
+
}
|
|
528
|
+
const filePath = getConfigPath(scope);
|
|
529
|
+
if (!existsSync2(filePath))
|
|
530
|
+
return false;
|
|
531
|
+
let existing;
|
|
532
|
+
try {
|
|
533
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
534
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
535
|
+
return false;
|
|
536
|
+
existing = parsed;
|
|
537
|
+
} catch {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
if (!(key in existing))
|
|
541
|
+
return false;
|
|
542
|
+
delete existing[key];
|
|
543
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2) + `
|
|
544
|
+
`);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/lib/selection-cache.ts
|
|
549
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
550
|
+
import { spawnSync } from "child_process";
|
|
551
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync3, readlinkSync, readSync, renameSync, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
|
|
552
|
+
import { hostname } from "os";
|
|
553
|
+
import { dirname as dirname2, join as join4, parse, resolve as resolve2 } from "path";
|
|
554
|
+
import { isDeepStrictEqual } from "util";
|
|
555
|
+
|
|
556
|
+
// src/lib/skill-bundle.ts
|
|
557
|
+
import { createHash } from "crypto";
|
|
558
|
+
import { readFileSync as readFileSync2, readdirSync, statSync } from "fs";
|
|
559
|
+
import { join as join3, relative } from "path";
|
|
560
|
+
import { createGunzip } from "zlib";
|
|
561
|
+
|
|
562
|
+
// src/lib/skill-entry-path.ts
|
|
563
|
+
class SkillEntryPaths {
|
|
564
|
+
files = new Set;
|
|
565
|
+
directories = new Set;
|
|
566
|
+
add(path, maxBytes, invalid, limit) {
|
|
567
|
+
if (path.length > maxBytes)
|
|
568
|
+
limit();
|
|
569
|
+
const encoded = new TextEncoder().encode(path);
|
|
570
|
+
if (encoded.byteLength > maxBytes)
|
|
571
|
+
limit();
|
|
572
|
+
if (new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(encoded) !== path)
|
|
573
|
+
invalid("Invalid UTF-8 entry path");
|
|
574
|
+
if (!path || /[\\:\x00-\x1f\x7f]/u.test(path))
|
|
575
|
+
invalid("Unsafe entry path");
|
|
576
|
+
if (path.split("/").some((segment) => !segment || segment === "." || segment === ".."))
|
|
577
|
+
invalid("Unsafe entry path segment");
|
|
578
|
+
const key = path.normalize("NFC").toLowerCase().normalize("NFC");
|
|
579
|
+
if (this.files.has(key) || this.directories.has(key))
|
|
580
|
+
invalid("Duplicate or conflicting entry path");
|
|
581
|
+
const parents = key.split("/");
|
|
582
|
+
parents.pop();
|
|
583
|
+
while (parents.length) {
|
|
584
|
+
const parent = parents.join("/");
|
|
585
|
+
if (this.files.has(parent))
|
|
586
|
+
invalid("Conflicting entry file ancestor");
|
|
587
|
+
this.directories.add(parent);
|
|
588
|
+
parents.pop();
|
|
589
|
+
}
|
|
590
|
+
this.files.add(key);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/lib/skill-bundle.ts
|
|
595
|
+
var BLOCK = 512;
|
|
596
|
+
var ANY_SEGMENT_EXCLUDES = new Set([
|
|
597
|
+
".git",
|
|
598
|
+
".ds_store",
|
|
599
|
+
".system",
|
|
600
|
+
"node_modules",
|
|
601
|
+
".aws",
|
|
602
|
+
".ssh",
|
|
603
|
+
".gnupg",
|
|
604
|
+
".docker"
|
|
605
|
+
]);
|
|
606
|
+
var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
|
|
607
|
+
var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json", ".skills-dependency-preparation"]);
|
|
608
|
+
var CREDENTIAL_FILENAMES = new Set([
|
|
609
|
+
".npmrc",
|
|
610
|
+
".pypirc",
|
|
611
|
+
".netrc",
|
|
612
|
+
".envrc",
|
|
613
|
+
".pgpass",
|
|
614
|
+
".git-credentials",
|
|
615
|
+
"credentials",
|
|
616
|
+
"id_rsa",
|
|
617
|
+
"id_dsa",
|
|
618
|
+
"id_ecdsa",
|
|
619
|
+
"id_ed25519"
|
|
620
|
+
]);
|
|
621
|
+
var CREDENTIAL_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".keystore", ".jks"];
|
|
622
|
+
var ENV_TEMPLATE_NAMES = new Set([".env.example", ".env.sample", ".env.template", ".env.dist"]);
|
|
623
|
+
var NON_DOTENV_EXTENSIONS = new Set([
|
|
624
|
+
"ts",
|
|
625
|
+
"tsx",
|
|
626
|
+
"mts",
|
|
627
|
+
"cts",
|
|
628
|
+
"js",
|
|
629
|
+
"jsx",
|
|
630
|
+
"mjs",
|
|
631
|
+
"cjs",
|
|
632
|
+
"json",
|
|
633
|
+
"jsonc",
|
|
634
|
+
"json5",
|
|
635
|
+
"py",
|
|
636
|
+
"rb",
|
|
637
|
+
"go",
|
|
638
|
+
"rs",
|
|
639
|
+
"java",
|
|
640
|
+
"kt",
|
|
641
|
+
"kts",
|
|
642
|
+
"swift",
|
|
643
|
+
"c",
|
|
644
|
+
"h",
|
|
645
|
+
"cc",
|
|
646
|
+
"cpp",
|
|
647
|
+
"hpp",
|
|
648
|
+
"cs",
|
|
649
|
+
"php",
|
|
650
|
+
"sh",
|
|
651
|
+
"bash",
|
|
652
|
+
"zsh",
|
|
653
|
+
"fish",
|
|
654
|
+
"ps1",
|
|
655
|
+
"bat",
|
|
656
|
+
"cmd",
|
|
657
|
+
"lua",
|
|
658
|
+
"pl",
|
|
659
|
+
"pm",
|
|
660
|
+
"r",
|
|
661
|
+
"jl",
|
|
662
|
+
"dart",
|
|
663
|
+
"ex",
|
|
664
|
+
"exs",
|
|
665
|
+
"scala",
|
|
666
|
+
"clj",
|
|
667
|
+
"cljs",
|
|
668
|
+
"groovy",
|
|
669
|
+
"gradle",
|
|
670
|
+
"vb",
|
|
671
|
+
"fs",
|
|
672
|
+
"yaml",
|
|
673
|
+
"yml",
|
|
674
|
+
"toml",
|
|
675
|
+
"ini",
|
|
676
|
+
"xml",
|
|
677
|
+
"csv",
|
|
678
|
+
"tsv",
|
|
679
|
+
"html",
|
|
680
|
+
"htm",
|
|
681
|
+
"css",
|
|
682
|
+
"scss",
|
|
683
|
+
"sass",
|
|
684
|
+
"less",
|
|
685
|
+
"md",
|
|
686
|
+
"mdx",
|
|
687
|
+
"txt",
|
|
688
|
+
"rst",
|
|
689
|
+
"adoc",
|
|
690
|
+
"sql",
|
|
691
|
+
"graphql",
|
|
692
|
+
"gql",
|
|
693
|
+
"proto",
|
|
694
|
+
"d",
|
|
695
|
+
"png",
|
|
696
|
+
"jpg",
|
|
697
|
+
"jpeg",
|
|
698
|
+
"gif",
|
|
699
|
+
"svg",
|
|
700
|
+
"webp",
|
|
701
|
+
"ico",
|
|
702
|
+
"bmp",
|
|
703
|
+
"pdf",
|
|
704
|
+
"woff",
|
|
705
|
+
"woff2",
|
|
706
|
+
"ttf",
|
|
707
|
+
"otf",
|
|
708
|
+
"eot",
|
|
709
|
+
"mp3",
|
|
710
|
+
"mp4",
|
|
711
|
+
"wav",
|
|
712
|
+
"webm",
|
|
713
|
+
"zip",
|
|
714
|
+
"gz",
|
|
715
|
+
"tar",
|
|
716
|
+
"wasm"
|
|
717
|
+
]);
|
|
718
|
+
function isDotenvFile(lower) {
|
|
719
|
+
if (lower === ".env" || lower.startsWith(".env."))
|
|
720
|
+
return true;
|
|
721
|
+
if (lower === "env")
|
|
722
|
+
return true;
|
|
723
|
+
if (lower.startsWith("env.")) {
|
|
724
|
+
const extension = lower.slice(lower.lastIndexOf(".") + 1);
|
|
725
|
+
return !NON_DOTENV_EXTENSIONS.has(extension);
|
|
726
|
+
}
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
function isCredentialFile(name) {
|
|
730
|
+
const lower = name.toLowerCase();
|
|
731
|
+
if (ENV_TEMPLATE_NAMES.has(lower))
|
|
732
|
+
return false;
|
|
733
|
+
if (isDotenvFile(lower))
|
|
734
|
+
return true;
|
|
735
|
+
if (CREDENTIAL_FILENAMES.has(lower))
|
|
736
|
+
return true;
|
|
737
|
+
return CREDENTIAL_EXTENSIONS.some((extension) => lower.endsWith(extension));
|
|
738
|
+
}
|
|
739
|
+
function ownBytes(view) {
|
|
740
|
+
const source = view instanceof ArrayBuffer ? new Uint8Array(view) : view;
|
|
741
|
+
const out = new Uint8Array(new ArrayBuffer(source.byteLength));
|
|
742
|
+
out.set(source);
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
function sha256Hex(bytes) {
|
|
746
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
747
|
+
}
|
|
748
|
+
function collectSkillBundleEntries(dir) {
|
|
749
|
+
const entries = [];
|
|
750
|
+
walk(dir, dir, entries);
|
|
751
|
+
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
752
|
+
}
|
|
753
|
+
function walk(root, current, out) {
|
|
754
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
755
|
+
const absolute = join3(current, entry.name);
|
|
756
|
+
const rel = relative(root, absolute).split("\\").join("/");
|
|
757
|
+
const isRootLevel = !rel.includes("/");
|
|
758
|
+
if (ANY_SEGMENT_EXCLUDES.has(entry.name.toLowerCase()))
|
|
759
|
+
continue;
|
|
760
|
+
if (isRootLevel && ROOT_EXCLUDES.has(entry.name.toLowerCase()))
|
|
761
|
+
continue;
|
|
762
|
+
if (isRootLevel && TOOL_SIDECAR_FILENAMES.has(entry.name.toLowerCase()))
|
|
763
|
+
continue;
|
|
764
|
+
if (entry.name.startsWith("._"))
|
|
765
|
+
continue;
|
|
766
|
+
if (entry.isSymbolicLink())
|
|
767
|
+
continue;
|
|
768
|
+
if (entry.isDirectory()) {
|
|
769
|
+
walk(root, absolute, out);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (!entry.isFile())
|
|
773
|
+
continue;
|
|
774
|
+
if (isCredentialFile(entry.name))
|
|
775
|
+
continue;
|
|
776
|
+
const stats = statSync(absolute);
|
|
777
|
+
out.push({
|
|
778
|
+
path: rel,
|
|
779
|
+
bytes: ownBytes(readFileSync2(absolute)),
|
|
780
|
+
mode: stats.mode & 64 ? 493 : 420
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function packSkillBundle(dir, options = {}) {
|
|
785
|
+
const entries = collectSkillBundleEntries(dir);
|
|
786
|
+
if (entries.length === 0) {
|
|
787
|
+
throw new Error(`Nothing to pack: ${dir} contains no files after exclusions (.git, node_modules, dist, .env)`);
|
|
788
|
+
}
|
|
789
|
+
const unpackedByteSize = entries.reduce((sum, entry) => sum + entry.bytes.byteLength, 0);
|
|
790
|
+
const max = options.maxUnpackedBytes ?? 0;
|
|
791
|
+
if (max > 0 && unpackedByteSize > max) {
|
|
792
|
+
throw new Error(`Skill sources are ${unpackedByteSize} bytes, over the ${max} byte limit. Remove build output or large fixtures.`);
|
|
793
|
+
}
|
|
794
|
+
const tar = writeTar(entries);
|
|
795
|
+
const bytes = canonicalGzip(tar);
|
|
796
|
+
return {
|
|
797
|
+
bytes,
|
|
798
|
+
sha256: sha256Hex(bytes),
|
|
799
|
+
fileCount: entries.length,
|
|
800
|
+
unpackedByteSize,
|
|
801
|
+
paths: entries.map((entry) => entry.path)
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
function canonicalGzip(tar) {
|
|
805
|
+
const bytes = ownBytes(Bun.gzipSync(tar, { level: 6 }));
|
|
806
|
+
if (bytes.byteLength >= 10) {
|
|
807
|
+
bytes[4] = 0;
|
|
808
|
+
bytes[5] = 0;
|
|
809
|
+
bytes[6] = 0;
|
|
810
|
+
bytes[7] = 0;
|
|
811
|
+
bytes[9] = 255;
|
|
812
|
+
}
|
|
813
|
+
return bytes;
|
|
814
|
+
}
|
|
815
|
+
function unpackSkillBundle(bundle) {
|
|
816
|
+
return readTar(ownBytes(Bun.gunzipSync(ownBytes(bundle))));
|
|
817
|
+
}
|
|
818
|
+
function writeTar(entries) {
|
|
819
|
+
const blocks = [];
|
|
820
|
+
for (const entry of entries) {
|
|
821
|
+
blocks.push(ustarHeader(entry));
|
|
822
|
+
blocks.push(entry.bytes);
|
|
823
|
+
const remainder = entry.bytes.byteLength % BLOCK;
|
|
824
|
+
if (remainder !== 0)
|
|
825
|
+
blocks.push(new Uint8Array(new ArrayBuffer(BLOCK - remainder)));
|
|
826
|
+
}
|
|
827
|
+
blocks.push(new Uint8Array(new ArrayBuffer(BLOCK * 2)));
|
|
828
|
+
return concat(blocks);
|
|
829
|
+
}
|
|
830
|
+
function ustarHeader(entry) {
|
|
831
|
+
const header = new Uint8Array(new ArrayBuffer(BLOCK));
|
|
832
|
+
const encoder = new TextEncoder;
|
|
833
|
+
const put = (offset, length, value) => {
|
|
834
|
+
const encoded = encoder.encode(value);
|
|
835
|
+
if (encoded.byteLength > length) {
|
|
836
|
+
throw new Error(`Cannot pack '${entry.path}': field does not fit in a ustar header (${encoded.byteLength} > ${length})`);
|
|
837
|
+
}
|
|
838
|
+
header.set(encoded, offset);
|
|
839
|
+
};
|
|
840
|
+
if (encoder.encode(entry.path).byteLength > 100) {
|
|
841
|
+
throw new Error(`Cannot pack '${entry.path}': path is longer than the 100 bytes a ustar header holds`);
|
|
842
|
+
}
|
|
843
|
+
put(0, 100, entry.path);
|
|
844
|
+
put(100, 8, `${entry.mode.toString(8).padStart(7, "0")}\x00`);
|
|
845
|
+
put(108, 8, "0000000\x00");
|
|
846
|
+
put(116, 8, "0000000\x00");
|
|
847
|
+
put(124, 12, `${entry.bytes.byteLength.toString(8).padStart(11, "0")}\x00`);
|
|
848
|
+
put(136, 12, `${0 .toString(8).padStart(11, "0")}\x00`);
|
|
849
|
+
put(148, 8, " ");
|
|
850
|
+
put(156, 1, "0");
|
|
851
|
+
put(257, 6, "ustar\x00");
|
|
852
|
+
put(263, 2, "00");
|
|
853
|
+
let checksum = 0;
|
|
854
|
+
for (const byte of header)
|
|
855
|
+
checksum += byte;
|
|
856
|
+
put(148, 8, `${checksum.toString(8).padStart(6, "0")}\x00 `);
|
|
857
|
+
return header;
|
|
858
|
+
}
|
|
859
|
+
function readTar(tar) {
|
|
860
|
+
const decoder = new TextDecoder;
|
|
861
|
+
const entries = [];
|
|
862
|
+
let offset = 0;
|
|
863
|
+
let terminated = false;
|
|
864
|
+
while (offset + BLOCK <= tar.byteLength) {
|
|
865
|
+
const header = tar.subarray(offset, offset + BLOCK);
|
|
866
|
+
if (header.every((byte) => byte === 0)) {
|
|
867
|
+
terminated = true;
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
const path = trimNul(decoder.decode(header.subarray(0, 100)));
|
|
871
|
+
assertSafeEntryPath(path);
|
|
872
|
+
const typeflag = String.fromCharCode(header[156]);
|
|
873
|
+
if (typeflag !== "0" && typeflag !== "\x00") {
|
|
874
|
+
throw new Error(`unsafe bundle entry '${path}': only regular files are allowed, found tar type '${typeflag}'`);
|
|
875
|
+
}
|
|
876
|
+
const mode = Number.parseInt(trimNul(decoder.decode(header.subarray(100, 108))).trim() || "0", 8);
|
|
877
|
+
const size = Number.parseInt(trimNul(decoder.decode(header.subarray(124, 136))).trim() || "0", 8);
|
|
878
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
879
|
+
throw new Error(`corrupt bundle: unreadable size for '${path}'`);
|
|
880
|
+
offset += BLOCK;
|
|
881
|
+
if (offset + size > tar.byteLength)
|
|
882
|
+
throw new Error(`corrupt bundle: '${path}' claims ${size} bytes past the end of the archive`);
|
|
883
|
+
entries.push({ path, mode, bytes: ownBytes(tar.subarray(offset, offset + size)) });
|
|
884
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
885
|
+
}
|
|
886
|
+
if (!terminated) {
|
|
887
|
+
throw new Error("corrupt bundle: the archive ends without its terminating blocks, so it is truncated");
|
|
888
|
+
}
|
|
889
|
+
return entries;
|
|
890
|
+
}
|
|
891
|
+
function assertSafeEntryPath(path) {
|
|
892
|
+
if (!path)
|
|
893
|
+
throw new Error("corrupt bundle: an entry has an empty path");
|
|
894
|
+
const normalized = path.split("\\").join("/");
|
|
895
|
+
if (normalized.startsWith("/"))
|
|
896
|
+
throw new Error(`unsafe bundle entry '${path}': absolute paths are not allowed`);
|
|
897
|
+
if (/^[A-Za-z]:/.test(normalized))
|
|
898
|
+
throw new Error(`unsafe bundle entry '${path}': drive-qualified paths are not allowed`);
|
|
899
|
+
const segments = normalized.split("/");
|
|
900
|
+
if (segments.includes(".."))
|
|
901
|
+
throw new Error(`unsafe bundle entry '${path}': '..' is not allowed`);
|
|
902
|
+
}
|
|
903
|
+
function trimNul(value) {
|
|
904
|
+
const end = value.indexOf("\x00");
|
|
905
|
+
return end === -1 ? value : value.slice(0, end);
|
|
906
|
+
}
|
|
907
|
+
function concat(chunks) {
|
|
908
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
909
|
+
const merged = new Uint8Array(new ArrayBuffer(total));
|
|
910
|
+
let offset = 0;
|
|
911
|
+
for (const chunk of chunks) {
|
|
912
|
+
merged.set(chunk, offset);
|
|
913
|
+
offset += chunk.byteLength;
|
|
914
|
+
}
|
|
915
|
+
return merged;
|
|
916
|
+
}
|
|
917
|
+
var SKILL_BUNDLE_INSPECTION_LIMITS = Object.freeze({
|
|
918
|
+
compressedBytes: 16 * 1024 * 1024,
|
|
919
|
+
decompressedBytes: 64 * 1024 * 1024,
|
|
920
|
+
entries: 1024,
|
|
921
|
+
fileBytes: 16 * 1024 * 1024,
|
|
922
|
+
pathBytes: 100,
|
|
923
|
+
timeoutMs: 5000
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
class SkillBundleInspectionError extends Error {
|
|
927
|
+
code;
|
|
928
|
+
constructor(code, message) {
|
|
929
|
+
super(message);
|
|
930
|
+
this.code = code;
|
|
931
|
+
this.name = "SkillBundleInspectionError";
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
function invalidBundle(message) {
|
|
935
|
+
throw new SkillBundleInspectionError("BUNDLE_INVALID", message);
|
|
936
|
+
}
|
|
937
|
+
function inspectionLimits(options) {
|
|
938
|
+
const limits = { ...SKILL_BUNDLE_INSPECTION_LIMITS };
|
|
939
|
+
for (const key of Object.keys(options.limits ?? {})) {
|
|
940
|
+
if (!Object.hasOwn(limits, key))
|
|
941
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Unknown bundle limit");
|
|
942
|
+
const field = key;
|
|
943
|
+
const value = options.limits[field];
|
|
944
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > limits[field]) {
|
|
945
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle limits must be positive integers within the hard ceilings");
|
|
946
|
+
}
|
|
947
|
+
limits[field] = value;
|
|
948
|
+
}
|
|
949
|
+
return limits;
|
|
950
|
+
}
|
|
951
|
+
async function inspectSkillBundle(bundle, options = {}) {
|
|
952
|
+
const signal = options.signal;
|
|
953
|
+
const limits = inspectionLimits(options);
|
|
954
|
+
const deadline = performance.now() + limits.timeoutMs;
|
|
955
|
+
const check = () => {
|
|
956
|
+
if (signal?.aborted)
|
|
957
|
+
throw new SkillBundleInspectionError("BUNDLE_ABORTED", "Bundle inspection aborted");
|
|
958
|
+
if (performance.now() >= deadline)
|
|
959
|
+
throw new SkillBundleInspectionError("BUNDLE_TIMEOUT", "Bundle inspection deadline exceeded");
|
|
960
|
+
};
|
|
961
|
+
check();
|
|
962
|
+
if (bundle.byteLength > limits.compressedBytes)
|
|
963
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Compressed bundle exceeds byte limit");
|
|
964
|
+
const snapshot = ownBytes(bundle);
|
|
965
|
+
check();
|
|
966
|
+
const sha256 = sha256Hex(snapshot);
|
|
967
|
+
check();
|
|
968
|
+
const parser = new BoundedTarReader(limits, check);
|
|
969
|
+
const streamOptions = { chunkSize: 16 * 1024, highWaterMark: 16 * 1024 };
|
|
970
|
+
const decoder = createGunzip(streamOptions);
|
|
971
|
+
let terminalError;
|
|
972
|
+
const stop = (code) => {
|
|
973
|
+
terminalError ??= new SkillBundleInspectionError(code, code === "BUNDLE_ABORTED" ? "Bundle inspection aborted" : "Bundle inspection deadline exceeded");
|
|
974
|
+
decoder.destroy(terminalError);
|
|
975
|
+
};
|
|
976
|
+
const onAbort = () => stop("BUNDLE_ABORTED");
|
|
977
|
+
const expire = () => {
|
|
978
|
+
const remaining = deadline - performance.now();
|
|
979
|
+
if (remaining > 0)
|
|
980
|
+
timer = setTimeout(expire, Math.max(1, Math.ceil(remaining)));
|
|
981
|
+
else
|
|
982
|
+
stop("BUNDLE_TIMEOUT");
|
|
983
|
+
};
|
|
984
|
+
let timer = setTimeout(expire, Math.max(1, Math.ceil(deadline - performance.now())));
|
|
985
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
986
|
+
let decompressedByteSize = 0;
|
|
987
|
+
let bytesSinceYield = 0;
|
|
988
|
+
try {
|
|
989
|
+
check();
|
|
990
|
+
decoder.end(snapshot);
|
|
991
|
+
for await (const chunk of decoder) {
|
|
992
|
+
check();
|
|
993
|
+
decompressedByteSize += chunk.byteLength;
|
|
994
|
+
if (decompressedByteSize > limits.decompressedBytes)
|
|
995
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Decompressed bundle exceeds byte limit");
|
|
996
|
+
parser.push(chunk);
|
|
997
|
+
bytesSinceYield += chunk.byteLength;
|
|
998
|
+
if (bytesSinceYield >= 256 * 1024) {
|
|
999
|
+
await new Promise((resolve2) => setTimeout(resolve2, 0));
|
|
1000
|
+
bytesSinceYield = 0;
|
|
1001
|
+
check();
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
check();
|
|
1005
|
+
const entries = parser.finish();
|
|
1006
|
+
return {
|
|
1007
|
+
entries,
|
|
1008
|
+
sha256,
|
|
1009
|
+
compressedByteSize: snapshot.byteLength,
|
|
1010
|
+
decompressedByteSize,
|
|
1011
|
+
unpackedByteSize: parser.fileBytes,
|
|
1012
|
+
fileCount: entries.length
|
|
1013
|
+
};
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if (terminalError)
|
|
1016
|
+
throw terminalError;
|
|
1017
|
+
if (error instanceof SkillBundleInspectionError)
|
|
1018
|
+
throw error;
|
|
1019
|
+
throw new SkillBundleInspectionError("BUNDLE_INVALID", "Invalid or truncated gzip bundle");
|
|
1020
|
+
} finally {
|
|
1021
|
+
clearTimeout(timer);
|
|
1022
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1023
|
+
decoder.destroy();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
class BoundedTarReader {
|
|
1028
|
+
limits;
|
|
1029
|
+
check;
|
|
1030
|
+
header = new Uint8Array(BLOCK);
|
|
1031
|
+
headerOffset = 0;
|
|
1032
|
+
pending;
|
|
1033
|
+
bodyOffset = 0;
|
|
1034
|
+
padding = 0;
|
|
1035
|
+
zeroBlocks = 0;
|
|
1036
|
+
entries = [];
|
|
1037
|
+
paths = new SkillEntryPaths;
|
|
1038
|
+
fileBytes = 0;
|
|
1039
|
+
constructor(limits, check) {
|
|
1040
|
+
this.limits = limits;
|
|
1041
|
+
this.check = check;
|
|
1042
|
+
}
|
|
1043
|
+
push(chunk) {
|
|
1044
|
+
let offset = 0;
|
|
1045
|
+
while (offset < chunk.byteLength) {
|
|
1046
|
+
this.check();
|
|
1047
|
+
if (this.pending) {
|
|
1048
|
+
const count = Math.min(this.pending.bytes.byteLength - this.bodyOffset, chunk.byteLength - offset);
|
|
1049
|
+
this.pending.bytes.set(chunk.subarray(offset, offset + count), this.bodyOffset);
|
|
1050
|
+
offset += count;
|
|
1051
|
+
this.bodyOffset += count;
|
|
1052
|
+
if (this.bodyOffset === this.pending.bytes.byteLength) {
|
|
1053
|
+
this.entries.push(this.pending);
|
|
1054
|
+
this.pending = undefined;
|
|
1055
|
+
}
|
|
1056
|
+
} else if (this.padding) {
|
|
1057
|
+
const count = Math.min(this.padding, chunk.byteLength - offset);
|
|
1058
|
+
if (chunk.subarray(offset, offset + count).some((byte) => byte !== 0))
|
|
1059
|
+
invalidBundle("Nonzero tar body padding");
|
|
1060
|
+
offset += count;
|
|
1061
|
+
this.padding -= count;
|
|
1062
|
+
} else {
|
|
1063
|
+
const count = Math.min(BLOCK - this.headerOffset, chunk.byteLength - offset);
|
|
1064
|
+
this.header.set(chunk.subarray(offset, offset + count), this.headerOffset);
|
|
1065
|
+
offset += count;
|
|
1066
|
+
this.headerOffset += count;
|
|
1067
|
+
if (this.headerOffset === BLOCK) {
|
|
1068
|
+
this.readHeader();
|
|
1069
|
+
this.headerOffset = 0;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
finish() {
|
|
1075
|
+
this.check();
|
|
1076
|
+
if (this.pending || this.padding || this.headerOffset || this.zeroBlocks < 2)
|
|
1077
|
+
invalidBundle("Truncated tar bundle");
|
|
1078
|
+
return this.entries;
|
|
1079
|
+
}
|
|
1080
|
+
readHeader() {
|
|
1081
|
+
this.check();
|
|
1082
|
+
const h = this.header;
|
|
1083
|
+
if (h.every((byte) => byte === 0)) {
|
|
1084
|
+
this.zeroBlocks++;
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (this.zeroBlocks)
|
|
1088
|
+
invalidBundle("Nonzero tar data after terminator");
|
|
1089
|
+
if (this.entries.length >= this.limits.entries)
|
|
1090
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle entry limit exceeded");
|
|
1091
|
+
let checksum = 0;
|
|
1092
|
+
for (let i = 0;i < BLOCK; i++)
|
|
1093
|
+
checksum += i >= 148 && i < 156 ? 32 : h[i];
|
|
1094
|
+
if (tarOctal(h.subarray(148, 156)) !== checksum)
|
|
1095
|
+
invalidBundle("Invalid tar header checksum");
|
|
1096
|
+
if (new TextDecoder().decode(h.subarray(257, 265)) !== "ustar\x00" + "00")
|
|
1097
|
+
invalidBundle("Unsupported tar format");
|
|
1098
|
+
if (h[156] !== 48 && h[156] !== 0 || h.subarray(157, 257).some((b) => b !== 0) || h.subarray(345).some((b) => b !== 0))
|
|
1099
|
+
invalidBundle("Unsupported tar entry or path prefix");
|
|
1100
|
+
const mode = tarOctal(h.subarray(100, 108));
|
|
1101
|
+
if (mode > 511)
|
|
1102
|
+
invalidBundle("Unsupported tar permission bits");
|
|
1103
|
+
tarOctal(h.subarray(108, 116));
|
|
1104
|
+
tarOctal(h.subarray(116, 124));
|
|
1105
|
+
tarOctal(h.subarray(136, 148));
|
|
1106
|
+
const size = tarOctal(h.subarray(124, 136));
|
|
1107
|
+
if (size > this.limits.fileBytes || this.fileBytes + size > this.limits.decompressedBytes) {
|
|
1108
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle file byte limit exceeded");
|
|
1109
|
+
}
|
|
1110
|
+
const name = h.subarray(0, 100);
|
|
1111
|
+
const end = name.indexOf(0);
|
|
1112
|
+
if (end !== -1 && name.subarray(end).some((b) => b !== 0))
|
|
1113
|
+
invalidBundle("Invalid tar path padding");
|
|
1114
|
+
const raw = end === -1 ? name : name.subarray(0, end);
|
|
1115
|
+
if (raw.byteLength > this.limits.pathBytes)
|
|
1116
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
|
|
1117
|
+
let path;
|
|
1118
|
+
try {
|
|
1119
|
+
path = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
|
|
1120
|
+
} catch {
|
|
1121
|
+
return invalidBundle("Invalid UTF-8 bundle path");
|
|
1122
|
+
}
|
|
1123
|
+
this.paths.add(path, this.limits.pathBytes, invalidBundle, () => {
|
|
1124
|
+
throw new SkillBundleInspectionError("BUNDLE_LIMIT", "Bundle path byte limit exceeded");
|
|
1125
|
+
});
|
|
1126
|
+
this.fileBytes += size;
|
|
1127
|
+
this.pending = { path, mode, bytes: new Uint8Array(new ArrayBuffer(size)) };
|
|
1128
|
+
this.bodyOffset = 0;
|
|
1129
|
+
this.padding = (BLOCK - size % BLOCK) % BLOCK;
|
|
1130
|
+
if (!size) {
|
|
1131
|
+
this.entries.push(this.pending);
|
|
1132
|
+
this.pending = undefined;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
function tarOctal(field) {
|
|
1137
|
+
const text = new TextDecoder().decode(field);
|
|
1138
|
+
if (!/^[0-7]+[\0 ]*$/.test(text))
|
|
1139
|
+
invalidBundle("Invalid tar octal field");
|
|
1140
|
+
const value = Number.parseInt(text, 8);
|
|
1141
|
+
if (!Number.isSafeInteger(value))
|
|
1142
|
+
invalidBundle("Tar integer is out of range");
|
|
1143
|
+
return value;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/lib/skill-version.ts
|
|
1147
|
+
var SKILL_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
|
|
1148
|
+
function isValidSkillVersion(value) {
|
|
1149
|
+
return typeof value === "string" && SKILL_VERSION_PATTERN.test(value) && !/^\.+$/.test(value) && !value.includes("..");
|
|
1150
|
+
}
|
|
1151
|
+
var SKILL_VERSION_RULE = "1-128 characters: letters, digits, '.', '_', '+', '-'; must start with a letter or digit; no '..'";
|
|
1152
|
+
|
|
1153
|
+
// src/lib/selection-aliases.ts
|
|
1154
|
+
function selectionAliasError(selections) {
|
|
1155
|
+
const names = new Set(selections.map((selection) => selection.slug));
|
|
1156
|
+
for (const selection of selections) {
|
|
1157
|
+
if (selection.aliases === undefined)
|
|
1158
|
+
continue;
|
|
1159
|
+
if (!Array.isArray(selection.aliases) || selection.aliases.length > 32)
|
|
1160
|
+
return "Selection aliases must be an array of at most 32 names.";
|
|
1161
|
+
for (const alias of selection.aliases) {
|
|
1162
|
+
if (typeof alias !== "string" || alias.length > 128 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(alias))
|
|
1163
|
+
return "Selection aliases must be bounded kebab-case names.";
|
|
1164
|
+
if (names.has(alias))
|
|
1165
|
+
return "Selection aliases must be unique and cannot shadow a selected canonical name.";
|
|
1166
|
+
names.add(alias);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
function selectionMatchesName(selection, name) {
|
|
1171
|
+
return selection.slug === name || Boolean(selection.aliases?.includes(name));
|
|
1172
|
+
}
|
|
1173
|
+
function selectionSnapshotsEqual(left, right) {
|
|
1174
|
+
const snapshot = (selections) => selections.map((selection) => [
|
|
1175
|
+
selection.slug,
|
|
1176
|
+
selection.version,
|
|
1177
|
+
selection.bundleDigest,
|
|
1178
|
+
selection.aliases ?? [],
|
|
1179
|
+
selection.triggers?.keywords ?? [],
|
|
1180
|
+
selection.triggers?.paths ?? [],
|
|
1181
|
+
selection.triggers?.always ?? null
|
|
1182
|
+
]).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
|
|
1183
|
+
return JSON.stringify(snapshot(left)) === JSON.stringify(snapshot(right));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/lib/profile-limits.ts
|
|
1187
|
+
var MAX_PROFILE_SELECTIONS = 4096;
|
|
1188
|
+
var MAX_PROFILE_DOCUMENT_BYTES = 8 * 1024 * 1024;
|
|
1189
|
+
var MAX_SKILL_SESSION_ID_CHARS = 256;
|
|
1190
|
+
var PROFILE_SESSION_ENVELOPE_BYTES = MAX_PROFILE_SELECTIONS * 67 + MAX_SKILL_SESSION_ID_CHARS * 12 + 512;
|
|
1191
|
+
var MAX_RESOLVED_PROFILE_BYTES = MAX_PROFILE_DOCUMENT_BYTES - PROFILE_SESSION_ENVELOPE_BYTES;
|
|
1192
|
+
var LEGACY_PROFILE_SELECTIONS = 256;
|
|
1193
|
+
var LEGACY_PROFILE_DOCUMENT_BYTES = 1e6;
|
|
1194
|
+
function profileDocumentBytes(value) {
|
|
1195
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1196
|
+
}
|
|
1197
|
+
function requiresProfileCapacity(selections, bodyBytes) {
|
|
1198
|
+
return selections.length > LEGACY_PROFILE_SELECTIONS || bodyBytes > LEGACY_PROFILE_DOCUMENT_BYTES;
|
|
1199
|
+
}
|
|
1200
|
+
function assertAdvertisedProfileCapacity(value, count, bodyBytes) {
|
|
1201
|
+
const limits = value?.profileLimits;
|
|
1202
|
+
if (!limits || !Number.isSafeInteger(limits.maxSelections) || Number(limits.maxSelections) < count || !Number.isSafeInteger(limits.maxDocumentBytes) || Number(limits.maxDocumentBytes) < bodyBytes || !Number.isSafeInteger(limits.requestBodyLimitBytes) || Number(limits.requestBodyLimitBytes) < bodyBytes || !Number.isSafeInteger(limits.maxResolvedProfileBytes) || Number(limits.maxResolvedProfileBytes) <= 0) {
|
|
1203
|
+
throw new Error("The Skills API does not advertise enough profile capacity. Review the API version and its configured request-body limit before saving this profile.");
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
function resolvedProfileSnapshot(profile, authority) {
|
|
1207
|
+
return {
|
|
1208
|
+
profileId: profile.id,
|
|
1209
|
+
workspaceId: profile.workspaceId,
|
|
1210
|
+
profileRevision: profile.revision,
|
|
1211
|
+
authority,
|
|
1212
|
+
selections: profile.selections.map((selection) => ({ ...selection, authority, workspaceId: profile.workspaceId, profileRevision: profile.revision }))
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
function profileSelectionSnapshot(selections) {
|
|
1216
|
+
return selections.map(({ slug, version: version2, bundleDigest, aliases, triggers }) => ({
|
|
1217
|
+
slug,
|
|
1218
|
+
version: version2,
|
|
1219
|
+
bundleDigest,
|
|
1220
|
+
...aliases !== undefined ? { aliases } : {},
|
|
1221
|
+
...triggers !== undefined ? { triggers } : {}
|
|
1222
|
+
}));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// src/lib/selection-cache.ts
|
|
1226
|
+
class SkillSelectionError extends Error {
|
|
1227
|
+
code;
|
|
1228
|
+
constructor(code, message, options) {
|
|
1229
|
+
super(message, options);
|
|
1230
|
+
this.code = code;
|
|
1231
|
+
this.name = "SkillSelectionError";
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
var MAX_CACHED_PROFILE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1235
|
+
var SELECTION_LOCK_FILE = "selection.lock.json";
|
|
1236
|
+
function selectionCacheRoot(options = {}) {
|
|
1237
|
+
return resolve2(options.cacheDir ?? join4(getDataDirReadOnly(), "selection-cache"));
|
|
1238
|
+
}
|
|
1239
|
+
function selectionKey(selection) {
|
|
1240
|
+
validateSelection(selection);
|
|
1241
|
+
return hash([selection.authority, selection.workspaceId, selection.slug, selection.version, selection.bundleDigest]);
|
|
1242
|
+
}
|
|
1243
|
+
function hash(value) {
|
|
1244
|
+
return createHash2("sha256").update(JSON.stringify(value)).digest("hex");
|
|
1245
|
+
}
|
|
1246
|
+
function validateSelection(selection) {
|
|
1247
|
+
if (!selection || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(selection.slug) || !isValidSkillVersion(selection.version) || !/^sha256:[a-f0-9]{64}$/.test(selection.bundleDigest) || typeof selection.workspaceId !== "string" || !selection.workspaceId.trim() || selection.authorizationEpoch !== undefined && (typeof selection.authorizationEpoch !== "string" || !/^[a-f0-9]{32}$/.test(selection.authorizationEpoch)) || typeof selection.profileRevision !== "string" || !selection.profileRevision.trim()) {
|
|
1248
|
+
throw new SkillSelectionError("INVALID_SELECTION", "A skill selection must carry an exact version, digest, workspace and profile revision.");
|
|
1249
|
+
}
|
|
1250
|
+
const aliasError = selectionAliasError([selection]);
|
|
1251
|
+
if (aliasError)
|
|
1252
|
+
throw new SkillSelectionError("INVALID_SELECTION_ALIASES", aliasError);
|
|
1253
|
+
try {
|
|
1254
|
+
const url = new URL(selection.authority);
|
|
1255
|
+
if (url.username || url.password || url.search || url.hash || !["https:", "http:"].includes(url.protocol) || url.protocol === "http:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))
|
|
1256
|
+
throw new Error;
|
|
1257
|
+
} catch {
|
|
1258
|
+
throw new SkillSelectionError("INVALID_SELECTION", "The skill selection authority is invalid.");
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
function validateResolvedProfile(profile, authority) {
|
|
1262
|
+
if (!profile || typeof profile.profileId !== "string" || !profile.profileId.trim() || !Array.isArray(profile.selections) || profile.selections.length > MAX_PROFILE_SELECTIONS || typeof profile.workspaceId !== "string" || !profile.workspaceId.trim() || typeof profile.profileRevision !== "string" || !profile.profileRevision.trim() || typeof profile.authority !== "string" || authority !== undefined && profile.authority !== authority) {
|
|
1263
|
+
throw new SkillSelectionError("PROFILE_IDENTITY_MISMATCH", "The resolved profile does not match the configured Skills authority.");
|
|
1264
|
+
}
|
|
1265
|
+
if (profileDocumentBytes(profile) > MAX_RESOLVED_PROFILE_BYTES)
|
|
1266
|
+
throw new SkillSelectionError("RECEIPT_TOO_LARGE", "The resolved Skills profile exceeds its size limit.");
|
|
1267
|
+
const slugs = new Set;
|
|
1268
|
+
for (const selection of profile.selections) {
|
|
1269
|
+
validateSelection(selection);
|
|
1270
|
+
if (selection.authority !== profile.authority || selection.workspaceId !== profile.workspaceId || selection.profileRevision !== profile.profileRevision || slugs.has(selection.slug)) {
|
|
1271
|
+
throw new SkillSelectionError("PROFILE_IDENTITY_MISMATCH", "The resolved profile contains conflicting selection identities.");
|
|
1272
|
+
}
|
|
1273
|
+
slugs.add(selection.slug);
|
|
1274
|
+
}
|
|
1275
|
+
const aliasError = selectionAliasError(profile.selections);
|
|
1276
|
+
if (aliasError)
|
|
1277
|
+
throw new SkillSelectionError("INVALID_SELECTION_ALIASES", aliasError);
|
|
1278
|
+
}
|
|
1279
|
+
function selectionBundlePath(selection, options = {}) {
|
|
1280
|
+
validateSelection(selection);
|
|
1281
|
+
return join4(selectionCacheRoot(options), "objects", hash(selection.authority), hash(selection.workspaceId), `${selection.bundleDigest.slice(7)}.tar.gz`);
|
|
1282
|
+
}
|
|
1283
|
+
function assertRegularPath(path, createParents = false) {
|
|
1284
|
+
const absolute = resolve2(path);
|
|
1285
|
+
const root = parse(absolute).root;
|
|
1286
|
+
let cursor = root;
|
|
1287
|
+
const segments = absolute.slice(root.length).split("/");
|
|
1288
|
+
for (let index = 0;index < segments.length; index++) {
|
|
1289
|
+
cursor = join4(cursor, segments[index]);
|
|
1290
|
+
try {
|
|
1291
|
+
const stat = lstatSync(cursor);
|
|
1292
|
+
if (stat.isSymbolicLink() || index < segments.length - 1 && !stat.isDirectory()) {
|
|
1293
|
+
throw new SkillSelectionError("UNSAFE_CACHE_PATH", "Skills cache paths must not traverse symlinks or non-directories.");
|
|
1294
|
+
}
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
if (error.code !== "ENOENT")
|
|
1297
|
+
throw error;
|
|
1298
|
+
if (createParents && index < segments.length - 1)
|
|
1299
|
+
mkdirSync2(cursor, { mode: 448 });
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
function readRegularFile(path, limit) {
|
|
1304
|
+
assertRegularPath(path);
|
|
1305
|
+
let fd;
|
|
1306
|
+
try {
|
|
1307
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1308
|
+
} catch (error) {
|
|
1309
|
+
if (error.code === "ENOENT")
|
|
1310
|
+
return null;
|
|
1311
|
+
throw error;
|
|
1312
|
+
}
|
|
1313
|
+
try {
|
|
1314
|
+
const stat = fstatSync(fd);
|
|
1315
|
+
if (!stat.isFile() || stat.size > limit)
|
|
1316
|
+
throw new SkillSelectionError("INVALID_CACHE_FILE", "The Skills cache contains an invalid or oversized file.");
|
|
1317
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
1318
|
+
let size = 0;
|
|
1319
|
+
while (size < bytes.length) {
|
|
1320
|
+
const read = readSync(fd, bytes, size, bytes.length - size, null);
|
|
1321
|
+
if (!read)
|
|
1322
|
+
break;
|
|
1323
|
+
size += read;
|
|
1324
|
+
}
|
|
1325
|
+
if (size > stat.size)
|
|
1326
|
+
throw new SkillSelectionError("INVALID_CACHE_FILE", "The Skills cache file grew during its bounded read.");
|
|
1327
|
+
return bytes.subarray(0, size);
|
|
1328
|
+
} finally {
|
|
1329
|
+
closeSync(fd);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
function writeSelectionJson(path, value) {
|
|
1333
|
+
const bytes = Buffer.from(`${JSON.stringify(value)}
|
|
1334
|
+
`);
|
|
1335
|
+
if (bytes.length > MAX_PROFILE_DOCUMENT_BYTES)
|
|
1336
|
+
throw new SkillSelectionError("RECEIPT_TOO_LARGE", "The Skills selection receipt exceeds its size limit.");
|
|
1337
|
+
atomicWrite(path, bytes, 384);
|
|
1338
|
+
}
|
|
1339
|
+
function atomicWrite(path, bytes, mode, durable = false) {
|
|
1340
|
+
assertRegularPath(path, true);
|
|
1341
|
+
const temporary = join4(dirname2(path), `.selection-${randomUUID()}.tmp`);
|
|
1342
|
+
try {
|
|
1343
|
+
const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
1344
|
+
try {
|
|
1345
|
+
writeFileSync2(fd, bytes);
|
|
1346
|
+
if (durable)
|
|
1347
|
+
fsyncSync(fd);
|
|
1348
|
+
} finally {
|
|
1349
|
+
closeSync(fd);
|
|
1350
|
+
}
|
|
1351
|
+
renameSync(temporary, path);
|
|
1352
|
+
if (durable)
|
|
1353
|
+
syncDirectory(dirname2(path));
|
|
1354
|
+
} finally {
|
|
1355
|
+
try {
|
|
1356
|
+
unlinkSync(temporary);
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
if (error.code !== "ENOENT")
|
|
1359
|
+
throw error;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function readSelectionJson(path, maximumBytes = MAX_PROFILE_DOCUMENT_BYTES) {
|
|
1364
|
+
const bytes = readRegularFile(path, Math.min(maximumBytes, MAX_PROFILE_DOCUMENT_BYTES));
|
|
1365
|
+
if (!bytes)
|
|
1366
|
+
return null;
|
|
1367
|
+
try {
|
|
1368
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1369
|
+
} catch {
|
|
1370
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The Skills selection receipt is unreadable; sync the profile again.");
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
async function verifiedEntries(selection, bytes, signal, deadline) {
|
|
1374
|
+
if (`sha256:${sha256Hex(bytes)}` !== selection.bundleDigest)
|
|
1375
|
+
throw new SkillSelectionError("BUNDLE_DIGEST_MISMATCH", "The skill bundle does not match its selected digest.");
|
|
1376
|
+
const remaining = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
|
|
1377
|
+
signal?.throwIfAborted();
|
|
1378
|
+
if (remaining !== undefined && remaining <= 0)
|
|
1379
|
+
throw new SkillBundleInspectionError("BUNDLE_TIMEOUT", "Bundle inspection deadline exceeded");
|
|
1380
|
+
const bundle = await inspectSkillBundle(bytes, { signal, ...remaining === undefined ? {} : { limits: { timeoutMs: remaining } } });
|
|
1381
|
+
return bundle.entries;
|
|
1382
|
+
}
|
|
1383
|
+
async function readCachedSelection(selection, options = {}) {
|
|
1384
|
+
const bytes = readRegularFile(selectionBundlePath(selection, options), SKILL_BUNDLE_INSPECTION_LIMITS.compressedBytes);
|
|
1385
|
+
return bytes ? verifiedEntries(selection, bytes) : null;
|
|
1386
|
+
}
|
|
1387
|
+
async function verifySelectionBundleResponse(selection, response, signal, deadline) {
|
|
1388
|
+
validateSelection(selection);
|
|
1389
|
+
if (!response?.ok)
|
|
1390
|
+
throw new SkillSelectionError("BUNDLE_UNAVAILABLE", `The selected skill bundle is unavailable${response ? ` (HTTP ${response.status})` : ""}.`);
|
|
1391
|
+
const declared = response.headers.get("X-Skill-Bundle-Sha256");
|
|
1392
|
+
const version2 = response.headers.get("X-Skill-Version");
|
|
1393
|
+
if (declared && `sha256:${declared}` !== selection.bundleDigest || version2 && version2 !== selection.version) {
|
|
1394
|
+
await response.body?.cancel();
|
|
1395
|
+
throw new SkillSelectionError("BUNDLE_IDENTITY_MISMATCH", "The returned bundle does not match the selected version and digest.");
|
|
1396
|
+
}
|
|
1397
|
+
const epoch = response.headers.get("X-Skill-Authorization-Epoch");
|
|
1398
|
+
if (selection.authorizationEpoch !== undefined && epoch !== selection.authorizationEpoch) {
|
|
1399
|
+
response.body?.cancel().catch(() => {});
|
|
1400
|
+
throw new SkillSelectionError("SESSION_RECONCILIATION_REQUIRED", "The selected bundle lifecycle authorization changed or is unavailable.");
|
|
1401
|
+
}
|
|
1402
|
+
const maximum = SKILL_BUNDLE_INSPECTION_LIMITS.compressedBytes;
|
|
1403
|
+
const reader = response.body?.getReader();
|
|
1404
|
+
if (!reader)
|
|
1405
|
+
throw new SkillSelectionError("BUNDLE_UNAVAILABLE", "The selected bundle response has no body.");
|
|
1406
|
+
signal?.throwIfAborted();
|
|
1407
|
+
const abort = () => {
|
|
1408
|
+
reader.cancel().catch(() => {});
|
|
1409
|
+
};
|
|
1410
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1411
|
+
const chunks = [];
|
|
1412
|
+
let length = 0;
|
|
1413
|
+
try {
|
|
1414
|
+
while (true) {
|
|
1415
|
+
signal?.throwIfAborted();
|
|
1416
|
+
const next = await reader.read();
|
|
1417
|
+
signal?.throwIfAborted();
|
|
1418
|
+
if (next.done)
|
|
1419
|
+
break;
|
|
1420
|
+
length += next.value.byteLength;
|
|
1421
|
+
if (length > maximum)
|
|
1422
|
+
throw new SkillSelectionError("BUNDLE_TOO_LARGE", "The selected bundle exceeds the cache size limit.");
|
|
1423
|
+
chunks.push(next.value);
|
|
1424
|
+
}
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
reader.cancel().catch(() => {});
|
|
1427
|
+
throw error;
|
|
1428
|
+
} finally {
|
|
1429
|
+
signal?.removeEventListener("abort", abort);
|
|
1430
|
+
reader.releaseLock();
|
|
1431
|
+
}
|
|
1432
|
+
const bytes = new Uint8Array(length);
|
|
1433
|
+
let offset = 0;
|
|
1434
|
+
for (const chunk of chunks) {
|
|
1435
|
+
bytes.set(chunk, offset);
|
|
1436
|
+
offset += chunk.byteLength;
|
|
1437
|
+
}
|
|
1438
|
+
const entries = await verifiedEntries(selection, bytes, signal, deadline);
|
|
1439
|
+
return { bytes, entries };
|
|
1440
|
+
}
|
|
1441
|
+
async function cacheSelectionBundle(selection, response, options = {}) {
|
|
1442
|
+
const { bytes, entries } = await verifySelectionBundleResponse(selection, response);
|
|
1443
|
+
const maximum = SKILL_BUNDLE_INSPECTION_LIMITS.compressedBytes;
|
|
1444
|
+
const path = selectionBundlePath(selection, options);
|
|
1445
|
+
const existing = readRegularFile(path, maximum);
|
|
1446
|
+
if (existing)
|
|
1447
|
+
await verifiedEntries(selection, existing);
|
|
1448
|
+
else
|
|
1449
|
+
atomicWrite(path, bytes, 256);
|
|
1450
|
+
return entries;
|
|
1451
|
+
}
|
|
1452
|
+
function profilePath(profileId, options) {
|
|
1453
|
+
return join4(selectionCacheRoot(options), "profiles", `${hash(profileId)}.json`);
|
|
1454
|
+
}
|
|
1455
|
+
function activateSelectionProfile(profile, options = {}) {
|
|
1456
|
+
validateResolvedProfile(profile);
|
|
1457
|
+
const receipt = { schemaVersion: 1, verifiedAt: new Date((options.now ?? Date.now)()).toISOString(), profile };
|
|
1458
|
+
writeSelectionJson(profilePath(profile.profileId, options), receipt);
|
|
1459
|
+
return receipt;
|
|
1460
|
+
}
|
|
1461
|
+
function readSelectionProfile(profileId, options = {}) {
|
|
1462
|
+
const receipt = readSelectionJson(profilePath(profileId, options));
|
|
1463
|
+
if (receipt)
|
|
1464
|
+
validateSelectionReceipt(receipt);
|
|
1465
|
+
return receipt;
|
|
1466
|
+
}
|
|
1467
|
+
function validateSelectionReceipt(receipt) {
|
|
1468
|
+
if (!receipt || receipt.schemaVersion !== 1 || !Number.isFinite(Date.parse(receipt.verifiedAt)))
|
|
1469
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The Skills selection receipt is invalid.");
|
|
1470
|
+
validateResolvedProfile(receipt.profile);
|
|
1471
|
+
}
|
|
1472
|
+
function assertFreshCachedProfile(receipt, options = {}) {
|
|
1473
|
+
validateSelectionReceipt(receipt);
|
|
1474
|
+
const age = (options.now ?? Date.now)() - Date.parse(receipt.verifiedAt);
|
|
1475
|
+
const maximum = options.maxAgeMs ?? MAX_CACHED_PROFILE_AGE_MS;
|
|
1476
|
+
if (!Number.isFinite(maximum) || maximum <= 0 || maximum > MAX_CACHED_PROFILE_AGE_MS)
|
|
1477
|
+
throw new SkillSelectionError("INVALID_CONTEXT_BUDGET", "The cached profile maximum age must be positive and within the supported bound.");
|
|
1478
|
+
if (!Number.isFinite(age) || age < 0)
|
|
1479
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The cached profile verification timestamp is invalid or in the future.");
|
|
1480
|
+
if (age > maximum) {
|
|
1481
|
+
throw new SkillSelectionError("CACHED_PROFILE_EXPIRED", "The cached Skills profile has expired; authenticate and sync it again.");
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
function projectSelectionLockPath(projectDir) {
|
|
1485
|
+
return join4(resolve2(projectDir), ".skills", SELECTION_LOCK_FILE);
|
|
1486
|
+
}
|
|
1487
|
+
function readProjectSelection(projectDir) {
|
|
1488
|
+
const receipt = readSelectionJson(projectSelectionLockPath(projectDir));
|
|
1489
|
+
if (receipt)
|
|
1490
|
+
validateSelectionReceipt(receipt);
|
|
1491
|
+
return receipt;
|
|
1492
|
+
}
|
|
1493
|
+
function sessionReceiptPath(sessionId, options = {}) {
|
|
1494
|
+
if (!sessionId.trim() || sessionId.length > MAX_SKILL_SESSION_ID_CHARS)
|
|
1495
|
+
throw new SkillSelectionError("INVALID_SESSION", `A Skills session id must contain 1\u2013${MAX_SKILL_SESSION_ID_CHARS} characters.`);
|
|
1496
|
+
return join4(selectionCacheRoot(options), "sessions", `${hash(sessionId)}.json`);
|
|
1497
|
+
}
|
|
1498
|
+
function skillSessionGeneration(receipt) {
|
|
1499
|
+
return receipt.generation ?? 0;
|
|
1500
|
+
}
|
|
1501
|
+
function validateSkillSessionBinding(binding) {
|
|
1502
|
+
if (!binding || typeof binding.sessionId !== "string" || !binding.sessionId.trim() || binding.sessionId.length > MAX_SKILL_SESSION_ID_CHARS || !Number.isSafeInteger(binding.generation) || binding.generation < 0 || !/^[a-f0-9]{64}$/.test(binding.receiptSha256)) {
|
|
1503
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The Skills session receipt contains an invalid parent snapshot binding.");
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
function validateSkillSessionReceipt(receipt, sessionId) {
|
|
1507
|
+
validateSelectionReceipt(receipt);
|
|
1508
|
+
const keys = new Set(receipt.profile.selections.map(selectionKey));
|
|
1509
|
+
if (receipt.sessionId !== sessionId || !Array.isArray(receipt.loaded) || receipt.loaded.length > keys.size || new Set(receipt.loaded).size !== receipt.loaded.length || !receipt.loaded.every((key) => keys.has(key)) || receipt.generation !== undefined && (!Number.isSafeInteger(receipt.generation) || receipt.generation < 1)) {
|
|
1510
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The Skills session receipt is invalid.");
|
|
1511
|
+
}
|
|
1512
|
+
if (receipt.parent) {
|
|
1513
|
+
validateSkillSessionBinding(receipt.parent);
|
|
1514
|
+
if (receipt.parent.sessionId === sessionId)
|
|
1515
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "A Skills child session cannot name itself as its parent.");
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function readSkillSessionSnapshotIfExists(sessionId, options = {}) {
|
|
1519
|
+
const path = sessionReceiptPath(sessionId, options), bytes = readRegularFile(path, MAX_PROFILE_DOCUMENT_BYTES);
|
|
1520
|
+
if (!bytes)
|
|
1521
|
+
return null;
|
|
1522
|
+
let receipt;
|
|
1523
|
+
try {
|
|
1524
|
+
receipt = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1525
|
+
} catch {
|
|
1526
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "The Skills session receipt is unreadable.");
|
|
1527
|
+
}
|
|
1528
|
+
validateSkillSessionReceipt(receipt, sessionId);
|
|
1529
|
+
const sha256 = sha256Hex(bytes);
|
|
1530
|
+
return { path, bytes, receipt, sha256, sessionId, generation: skillSessionGeneration(receipt), receiptSha256: sha256 };
|
|
1531
|
+
}
|
|
1532
|
+
function readSkillSessionSnapshot(sessionId, options = {}) {
|
|
1533
|
+
const snapshot = readSkillSessionSnapshotIfExists(sessionId, options);
|
|
1534
|
+
if (!snapshot)
|
|
1535
|
+
throw new SkillSelectionError("SESSION_NOT_FOUND", "The requested Skills session receipt does not exist.");
|
|
1536
|
+
return snapshot;
|
|
1537
|
+
}
|
|
1538
|
+
function skillSessionSnapshotBinding(snapshot) {
|
|
1539
|
+
return { sessionId: snapshot.sessionId, generation: snapshot.generation, receiptSha256: snapshot.receiptSha256 };
|
|
1540
|
+
}
|
|
1541
|
+
var SESSION_LOCK_MAX_BYTES = 4096;
|
|
1542
|
+
var SESSION_LOCK_MIN_AGE_MS = 30000;
|
|
1543
|
+
function localBootId() {
|
|
1544
|
+
try {
|
|
1545
|
+
if (process.platform === "linux") {
|
|
1546
|
+
const value = readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
1547
|
+
return /^[a-f0-9-]{36}$/i.test(value) ? value : null;
|
|
1548
|
+
}
|
|
1549
|
+
if (process.platform === "darwin") {
|
|
1550
|
+
const result = spawnSync("/usr/sbin/sysctl", ["-n", "kern.boottime"], { encoding: "utf8", timeout: 3000, env: { LC_ALL: "C" } });
|
|
1551
|
+
if (result.status !== 0 || result.error)
|
|
1552
|
+
return null;
|
|
1553
|
+
const seconds = /\bsec\s*=\s*(\d+)\b/.exec(result.stdout)?.[1];
|
|
1554
|
+
return seconds ? `darwin:${seconds}` : null;
|
|
1555
|
+
}
|
|
1556
|
+
} catch {}
|
|
1557
|
+
return null;
|
|
1558
|
+
}
|
|
1559
|
+
function processStart(pid) {
|
|
1560
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
1561
|
+
return { state: "unknown" };
|
|
1562
|
+
if (process.platform === "linux") {
|
|
1563
|
+
try {
|
|
1564
|
+
const stat = readFileSync3(`/proc/${pid}/stat`, "utf8");
|
|
1565
|
+
const close = stat.lastIndexOf(")");
|
|
1566
|
+
const start = close < 0 ? undefined : stat.slice(close + 2).trim().split(/\s+/)[19];
|
|
1567
|
+
return start && /^\d+$/.test(start) ? { state: "alive", start: `linux:${start}` } : { state: "unknown" };
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
return { state: error.code === "ENOENT" ? "absent" : "unknown" };
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
if (process.platform === "darwin") {
|
|
1573
|
+
const result = spawnSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", timeout: 3000, env: { LC_ALL: "C" } });
|
|
1574
|
+
if (result.error || result.signal)
|
|
1575
|
+
return { state: "unknown" };
|
|
1576
|
+
const start = result.stdout.trim();
|
|
1577
|
+
if (result.status === 1 && !start && !result.stderr.trim())
|
|
1578
|
+
return { state: "absent" };
|
|
1579
|
+
return result.status === 0 && start ? { state: "alive", start: `darwin:${start}` } : { state: "unknown" };
|
|
1580
|
+
}
|
|
1581
|
+
return { state: "unknown" };
|
|
1582
|
+
}
|
|
1583
|
+
function localLockIdentity() {
|
|
1584
|
+
const bootId = localBootId(), owner = processStart(process.pid);
|
|
1585
|
+
if (!bootId || owner.state !== "alive")
|
|
1586
|
+
return null;
|
|
1587
|
+
let pidNamespace;
|
|
1588
|
+
if (process.platform === "linux") {
|
|
1589
|
+
try {
|
|
1590
|
+
pidNamespace = readlinkSync("/proc/self/ns/pid");
|
|
1591
|
+
} catch {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
if (!/^pid:\[\d+\]$/.test(pidNamespace))
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
return { host: hostname(), bootId, pidNamespace, processStart: owner.start };
|
|
1598
|
+
}
|
|
1599
|
+
function readSessionLock(sessionId, options) {
|
|
1600
|
+
const path = `${sessionReceiptPath(sessionId, options)}.write-lock`;
|
|
1601
|
+
const before = lstatSync(path, { throwIfNoEntry: false });
|
|
1602
|
+
if (!before)
|
|
1603
|
+
return null;
|
|
1604
|
+
if (!before.isFile() || ![1, 2].includes(before.nlink) || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid()) {
|
|
1605
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is not a private regular file; recovery requires review.");
|
|
1606
|
+
}
|
|
1607
|
+
const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
|
|
1608
|
+
const after = lstatSync(path, { throwIfNoEntry: false });
|
|
1609
|
+
if (!bytes || !after?.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink) {
|
|
1610
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock changed during inspection; recovery requires review.");
|
|
1611
|
+
}
|
|
1612
|
+
let marker;
|
|
1613
|
+
try {
|
|
1614
|
+
marker = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1615
|
+
} catch {
|
|
1616
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock is unreadable; recovery requires review.");
|
|
1617
|
+
}
|
|
1618
|
+
if (!marker || ![1, 2].includes(marker.schemaVersion) || marker.sessionId !== sessionId || !/^[a-f0-9-]{36}$/i.test(marker.operationId) || !Number.isSafeInteger(marker.pid) || marker.pid <= 0 || marker.publication !== undefined && marker.publication !== "hard-link-v1" || marker.schemaVersion === 2 && (!marker.host || !marker.bootId || !marker.processStart || process.platform === "linux" && !marker.pidNamespace)) {
|
|
1619
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has invalid ownership metadata; recovery requires review.");
|
|
1620
|
+
}
|
|
1621
|
+
let stagingPath;
|
|
1622
|
+
if (after.nlink === 2) {
|
|
1623
|
+
stagingPath = `${path}.${marker.operationId}.prepared`;
|
|
1624
|
+
assertRegularPath(stagingPath);
|
|
1625
|
+
const staged = lstatSync(stagingPath, { throwIfNoEntry: false });
|
|
1626
|
+
if (marker.publication !== "hard-link-v1" || !staged?.isFile() || staged.dev !== after.dev || staged.ino !== after.ino || staged.nlink !== 2 || staged.mode !== after.mode || staged.uid !== after.uid || staged.gid !== after.gid) {
|
|
1627
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCKED", "The session lock has an unbound publication link; recovery requires review.");
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return { path, bytes, marker, sha256: sha256Hex(bytes), dev: after.dev, ino: after.ino, mtimeMs: after.mtimeMs, stagingPath };
|
|
1631
|
+
}
|
|
1632
|
+
function publishSessionLock(path, marker) {
|
|
1633
|
+
const stagingPath = `${path}.${marker.operationId}.prepared`;
|
|
1634
|
+
const bytes = Buffer.from(`${JSON.stringify({ ...marker, publication: "hard-link-v1" })}
|
|
1635
|
+
`);
|
|
1636
|
+
const fd = openSync(stagingPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
1637
|
+
const stat = fstatSync(fd);
|
|
1638
|
+
let published = false, handedOff = false;
|
|
1639
|
+
try {
|
|
1640
|
+
writeFileSync2(fd, bytes);
|
|
1641
|
+
fsyncSync(fd);
|
|
1642
|
+
linkSync(stagingPath, path);
|
|
1643
|
+
published = true;
|
|
1644
|
+
syncDirectory(dirname2(path));
|
|
1645
|
+
const staged = lstatSync(stagingPath);
|
|
1646
|
+
if (!staged.isFile() || staged.dev !== stat.dev || staged.ino !== stat.ino) {
|
|
1647
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The initialized lock staging inode changed before publication completed.");
|
|
1648
|
+
}
|
|
1649
|
+
unlinkSync(stagingPath);
|
|
1650
|
+
syncDirectory(dirname2(path));
|
|
1651
|
+
handedOff = true;
|
|
1652
|
+
return { path, stagingPath, fd, dev: stat.dev, ino: stat.ino };
|
|
1653
|
+
} finally {
|
|
1654
|
+
if (!handedOff) {
|
|
1655
|
+
try {
|
|
1656
|
+
for (const target of [...published ? [path] : [], stagingPath]) {
|
|
1657
|
+
const current = lstatSync(target, { throwIfNoEntry: false });
|
|
1658
|
+
if (current?.isFile() && current.dev === stat.dev && current.ino === stat.ino)
|
|
1659
|
+
unlinkSync(target);
|
|
1660
|
+
}
|
|
1661
|
+
} finally {
|
|
1662
|
+
closeSync(fd);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
function writerIsGone(lock, explicitLocalHost) {
|
|
1668
|
+
if (Date.now() - lock.mtimeMs < SESSION_LOCK_MIN_AGE_MS)
|
|
1669
|
+
return false;
|
|
1670
|
+
const owner = processStart(lock.marker.pid);
|
|
1671
|
+
if (owner.state === "unknown")
|
|
1672
|
+
return false;
|
|
1673
|
+
if (lock.marker.schemaVersion === 1)
|
|
1674
|
+
return explicitLocalHost && owner.state === "absent";
|
|
1675
|
+
const local = localLockIdentity();
|
|
1676
|
+
if (!local || lock.marker.host !== local.host || lock.marker.bootId !== local.bootId || lock.marker.pidNamespace !== local.pidNamespace)
|
|
1677
|
+
return false;
|
|
1678
|
+
return owner.state === "absent" || owner.state === "alive" && owner.start !== lock.marker.processStart;
|
|
1679
|
+
}
|
|
1680
|
+
function archiveSessionLock(lock, receipt, explicitLocalHost) {
|
|
1681
|
+
const guardPath = `${lock.path}.recovery-guard`;
|
|
1682
|
+
let guardFd;
|
|
1683
|
+
try {
|
|
1684
|
+
guardFd = openSync(guardPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if (error.code === "EEXIST")
|
|
1687
|
+
return null;
|
|
1688
|
+
throw error;
|
|
1689
|
+
}
|
|
1690
|
+
const guardStat = fstatSync(guardFd);
|
|
1691
|
+
try {
|
|
1692
|
+
writeFileSync2(guardFd, `${JSON.stringify({ schemaVersion: 1, pid: process.pid, operationId: randomUUID() })}
|
|
1693
|
+
`);
|
|
1694
|
+
fsyncSync(guardFd);
|
|
1695
|
+
const cacheOptions = { cacheDir: dirname2(dirname2(lock.path)) };
|
|
1696
|
+
const current = readSessionLock(lock.marker.sessionId, cacheOptions);
|
|
1697
|
+
if (!current || current.dev !== lock.dev || current.ino !== lock.ino || current.sha256 !== lock.sha256 || current.mtimeMs !== lock.mtimeMs || !writerIsGone(current, explicitLocalHost))
|
|
1698
|
+
return null;
|
|
1699
|
+
const currentReceipt = readSkillSessionSnapshotIfExists(lock.marker.sessionId, cacheOptions);
|
|
1700
|
+
if (currentReceipt?.sha256 !== receipt?.sha256 || currentReceipt?.generation !== receipt?.generation)
|
|
1701
|
+
return null;
|
|
1702
|
+
const directory = join4(dirname2(dirname2(lock.path)), "session-lock-recoveries", randomUUID());
|
|
1703
|
+
const archivePath = join4(directory, "original.write-lock");
|
|
1704
|
+
const recordPath = join4(directory, "recovery.json");
|
|
1705
|
+
assertRegularPath(archivePath, true);
|
|
1706
|
+
archiveBytes(recordPath, Buffer.from(`${JSON.stringify({
|
|
1707
|
+
schemaVersion: 1,
|
|
1708
|
+
sessionId: lock.marker.sessionId,
|
|
1709
|
+
originalPath: lock.path,
|
|
1710
|
+
lockSha256: lock.sha256,
|
|
1711
|
+
lockMtime: new Date(lock.mtimeMs).toISOString(),
|
|
1712
|
+
lockPid: lock.marker.pid,
|
|
1713
|
+
receiptSha256: receipt?.sha256 ?? null,
|
|
1714
|
+
receiptGeneration: receipt?.generation ?? null,
|
|
1715
|
+
status: "prepared"
|
|
1716
|
+
})}
|
|
1717
|
+
`));
|
|
1718
|
+
syncDirectory(directory);
|
|
1719
|
+
syncDirectory(dirname2(directory));
|
|
1720
|
+
const finalLock = readSessionLock(lock.marker.sessionId, cacheOptions);
|
|
1721
|
+
if (!finalLock || finalLock.dev !== lock.dev || finalLock.ino !== lock.ino || finalLock.sha256 !== lock.sha256 || readSkillSessionSnapshotIfExists(lock.marker.sessionId, cacheOptions)?.sha256 !== receipt?.sha256)
|
|
1722
|
+
return null;
|
|
1723
|
+
let moved = false;
|
|
1724
|
+
try {
|
|
1725
|
+
renameSync(lock.path, archivePath);
|
|
1726
|
+
moved = true;
|
|
1727
|
+
const archivedPaths = [archivePath];
|
|
1728
|
+
if (finalLock.stagingPath) {
|
|
1729
|
+
const publicationPath = join4(directory, "publication.write-lock");
|
|
1730
|
+
renameSync(finalLock.stagingPath, publicationPath);
|
|
1731
|
+
archivedPaths.push(publicationPath);
|
|
1732
|
+
}
|
|
1733
|
+
syncDirectory(directory);
|
|
1734
|
+
syncDirectory(dirname2(lock.path));
|
|
1735
|
+
for (const path of archivedPaths) {
|
|
1736
|
+
const before = lstatSync(path, { throwIfNoEntry: false });
|
|
1737
|
+
const bytes = readRegularFile(path, SESSION_LOCK_MAX_BYTES);
|
|
1738
|
+
const after = lstatSync(path, { throwIfNoEntry: false });
|
|
1739
|
+
if (!before?.isFile() || before.dev !== lock.dev || before.ino !== lock.ino || before.nlink !== archivedPaths.length || before.mtimeMs !== lock.mtimeMs || (before.mode & 63) !== 0 || process.getuid && before.uid !== process.getuid() || !bytes || sha256Hex(bytes) !== lock.sha256 || !after?.isFile() || before.dev !== after.dev || before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.mode !== after.mode || before.uid !== after.uid || before.gid !== after.gid || before.nlink !== after.nlink) {
|
|
1740
|
+
throw new Error("Archived lock ownership or bytes changed");
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
if (moved)
|
|
1745
|
+
throw new SkillSelectionError("SESSION_LOCK_RECOVERY_INCOMPLETE", "The lock moved, but durable recovery could not be confirmed; inspect the preserved recovery directory and session receipt.");
|
|
1746
|
+
throw error;
|
|
1747
|
+
}
|
|
1748
|
+
return archivePath;
|
|
1749
|
+
} finally {
|
|
1750
|
+
closeSync(guardFd);
|
|
1751
|
+
const current = lstatSync(guardPath, { throwIfNoEntry: false });
|
|
1752
|
+
if (current?.isFile() && current.dev === guardStat.dev && current.ino === guardStat.ino)
|
|
1753
|
+
unlinkSync(guardPath);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
function withSessionWriteLocks(sessionIds, options, action) {
|
|
1757
|
+
const operationId = randomUUID();
|
|
1758
|
+
const identity = localLockIdentity();
|
|
1759
|
+
const locks = [...new Set(sessionIds)].map((sessionId) => ({ sessionId, path: `${sessionReceiptPath(sessionId, options)}.write-lock` })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1760
|
+
const owned = [];
|
|
1761
|
+
const assertOwned = () => {
|
|
1762
|
+
for (const lock of owned) {
|
|
1763
|
+
const current = lstatSync(lock.path, { throwIfNoEntry: false });
|
|
1764
|
+
if (!current?.isFile() || current.dev !== lock.dev || current.ino !== lock.ino) {
|
|
1765
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "A session write lock changed during the operation; no replacement is permitted.");
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
};
|
|
1769
|
+
try {
|
|
1770
|
+
for (const lock of locks) {
|
|
1771
|
+
assertRegularPath(lock.path, true);
|
|
1772
|
+
const marker = { schemaVersion: identity ? 2 : 1, operationId, sessionId: lock.sessionId, pid: process.pid, ...identity };
|
|
1773
|
+
let acquired;
|
|
1774
|
+
try {
|
|
1775
|
+
acquired = publishSessionLock(lock.path, marker);
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
if (error.code !== "EEXIST")
|
|
1778
|
+
throw error;
|
|
1779
|
+
try {
|
|
1780
|
+
const stale = readSessionLock(lock.sessionId, options);
|
|
1781
|
+
const receipt = stale && readSkillSessionSnapshotIfExists(lock.sessionId, options);
|
|
1782
|
+
if (stale?.marker.schemaVersion === 2 && writerIsGone(stale, false)) {
|
|
1783
|
+
archiveSessionLock(stale, receipt, false);
|
|
1784
|
+
}
|
|
1785
|
+
} catch (recoveryError) {
|
|
1786
|
+
if (recoveryError instanceof SkillSelectionError && recoveryError.code === "SESSION_LOCK_RECOVERY_INCOMPLETE")
|
|
1787
|
+
throw recoveryError;
|
|
1788
|
+
}
|
|
1789
|
+
try {
|
|
1790
|
+
acquired = publishSessionLock(lock.path, marker);
|
|
1791
|
+
} catch (retryError) {
|
|
1792
|
+
if (retryError.code === "EEXIST") {
|
|
1793
|
+
if (!readSessionLock(lock.sessionId, options))
|
|
1794
|
+
throw new SkillSelectionError("SESSION_WRITE_LOCK_CHANGED", "The session lock changed during acquisition.");
|
|
1795
|
+
throw new SkillSelectionError("SESSION_WRITE_BUSY", "Another Skills process owns a required session write lock; retry after it completes. A surviving legacy lock requires explicit recovery review.");
|
|
1796
|
+
}
|
|
1797
|
+
throw retryError;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
owned.push(acquired);
|
|
1801
|
+
}
|
|
1802
|
+
return action(assertOwned);
|
|
1803
|
+
} finally {
|
|
1804
|
+
for (const lock of owned.reverse()) {
|
|
1805
|
+
try {
|
|
1806
|
+
for (const path of [lock.path, lock.stagingPath]) {
|
|
1807
|
+
const current = lstatSync(path, { throwIfNoEntry: false });
|
|
1808
|
+
if (current?.isFile() && current.dev === lock.dev && current.ino === lock.ino)
|
|
1809
|
+
unlinkSync(path);
|
|
1810
|
+
}
|
|
1811
|
+
} finally {
|
|
1812
|
+
closeSync(lock.fd);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function withSessionWriteLock(sessionId, options, action) {
|
|
1818
|
+
return withSessionWriteLocks([sessionId], options, action);
|
|
1819
|
+
}
|
|
1820
|
+
function sameSnapshot(actual, expected) {
|
|
1821
|
+
return actual === null ? expected === null : expected !== null && actual.sessionId === expected.sessionId && actual.generation === expected.generation && actual.receiptSha256 === expected.receiptSha256;
|
|
1822
|
+
}
|
|
1823
|
+
function nextSkillSessionGeneration(generation) {
|
|
1824
|
+
if (!Number.isSafeInteger(generation) || generation < 0 || generation >= Number.MAX_SAFE_INTEGER) {
|
|
1825
|
+
throw new SkillSelectionError("SESSION_GENERATION_EXHAUSTED", "The Skills session generation cannot advance safely; preserve the receipt and review recovery before continuing.");
|
|
1826
|
+
}
|
|
1827
|
+
return generation + 1;
|
|
1828
|
+
}
|
|
1829
|
+
function parentChanged() {
|
|
1830
|
+
throw new SkillSelectionError("SESSION_PARENT_CHANGED", "The parent Skills session changed while the child was resolving; resolve the child again from the current parent pin.");
|
|
1831
|
+
}
|
|
1832
|
+
function writeSkillSession(receipt, expected, options = {}) {
|
|
1833
|
+
validateSkillSessionReceipt(receipt, receipt.sessionId);
|
|
1834
|
+
if (expected.parent)
|
|
1835
|
+
validateSkillSessionBinding(expected.parent);
|
|
1836
|
+
if (expected.parent?.sessionId === receipt.sessionId)
|
|
1837
|
+
throw new SkillSelectionError("INVALID_RECEIPT", "A Skills child session cannot name itself as its parent.");
|
|
1838
|
+
const lockIds = expected.parent ? [receipt.sessionId, expected.parent.sessionId] : [receipt.sessionId];
|
|
1839
|
+
withSessionWriteLocks(lockIds, options, (assertOwned) => {
|
|
1840
|
+
const current = readSkillSessionSnapshotIfExists(receipt.sessionId, options);
|
|
1841
|
+
if (!sameSnapshot(current, expected.current) || current && !isDeepStrictEqual(current.receipt.profile, receipt.profile)) {
|
|
1842
|
+
throw new SkillSelectionError("SESSION_CONTEXT_CONFLICT", "The Skills session receipt changed while context was loading; resolve context again without replacing the current pin.");
|
|
1843
|
+
}
|
|
1844
|
+
let parent = null;
|
|
1845
|
+
if (expected.parent) {
|
|
1846
|
+
parent = readSkillSessionSnapshotIfExists(expected.parent.sessionId, options);
|
|
1847
|
+
if (!sameSnapshot(parent, expected.parent) || !parent || !isDeepStrictEqual(parent.receipt.profile, receipt.profile))
|
|
1848
|
+
parentChanged();
|
|
1849
|
+
}
|
|
1850
|
+
const generation = nextSkillSessionGeneration(current?.generation ?? 0);
|
|
1851
|
+
const next = current ? { ...current.receipt, generation, loaded: [...new Set([...current.receipt.loaded, ...receipt.loaded])] } : { ...receipt, generation, ...expected.parent ? { parent: expected.parent } : {} };
|
|
1852
|
+
assertOwned();
|
|
1853
|
+
if (expected.parent) {
|
|
1854
|
+
parent = readSkillSessionSnapshotIfExists(expected.parent.sessionId, options);
|
|
1855
|
+
if (!sameSnapshot(parent, expected.parent))
|
|
1856
|
+
parentChanged();
|
|
1857
|
+
}
|
|
1858
|
+
writeSelectionJson(sessionReceiptPath(receipt.sessionId, options), next);
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
function syncDirectory(path) {
|
|
1862
|
+
assertRegularPath(path);
|
|
1863
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
1864
|
+
try {
|
|
1865
|
+
fsyncSync(fd);
|
|
1866
|
+
} finally {
|
|
1867
|
+
closeSync(fd);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function archiveBytes(path, bytes) {
|
|
1871
|
+
assertRegularPath(path, true);
|
|
1872
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
1873
|
+
try {
|
|
1874
|
+
writeFileSync2(fd, bytes);
|
|
1875
|
+
fsyncSync(fd);
|
|
1876
|
+
} finally {
|
|
1877
|
+
closeSync(fd);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
function replaceSkillSession(expected, replacement, plan, options = {}, beforeCommit = () => {}) {
|
|
1881
|
+
validateSkillSessionReceipt(replacement, replacement.sessionId);
|
|
1882
|
+
const bytes = Buffer.from(`${JSON.stringify(replacement)}
|
|
1883
|
+
`);
|
|
1884
|
+
if (bytes.length > MAX_PROFILE_DOCUMENT_BYTES)
|
|
1885
|
+
throw new SkillSelectionError("RECEIPT_TOO_LARGE", "The Skills selection receipt exceeds its size limit.");
|
|
1886
|
+
return withSessionWriteLock(replacement.sessionId, options, (assertOwned) => {
|
|
1887
|
+
const before = readSkillSessionSnapshot(replacement.sessionId, options);
|
|
1888
|
+
if (!sameSnapshot(before, expected))
|
|
1889
|
+
throw new SkillSelectionError("SESSION_RECEIPT_CHANGED", "The Skills session receipt generation or bytes changed after review; inspect it and prepare a new plan.");
|
|
1890
|
+
if (replacement.generation !== nextSkillSessionGeneration(before.generation))
|
|
1891
|
+
throw new SkillSelectionError("SESSION_GENERATION_CHANGED", "The replacement Skills session must advance exactly one generation.");
|
|
1892
|
+
if (before.receipt.profile.authority !== replacement.profile.authority || before.receipt.profile.workspaceId !== replacement.profile.workspaceId) {
|
|
1893
|
+
throw new SkillSelectionError("PROFILE_IDENTITY_MISMATCH", "A session reconciliation cannot change its Skills authority or workspace.");
|
|
1894
|
+
}
|
|
1895
|
+
const directory = join4(selectionCacheRoot(options), "session-reconciliations", randomUUID());
|
|
1896
|
+
const archivePath = join4(directory, "original.json"), replacementPath = join4(directory, "replacement.json"), receiptPath = join4(directory, "receipt.json");
|
|
1897
|
+
const afterSha256 = sha256Hex(bytes);
|
|
1898
|
+
archiveBytes(archivePath, before.bytes);
|
|
1899
|
+
archiveBytes(replacementPath, bytes);
|
|
1900
|
+
const receipt = { schemaVersion: 1, status: "prepared", sessionId: replacement.sessionId, beforeGeneration: before.generation, afterGeneration: replacement.generation, beforeSha256: before.sha256, afterSha256, archivePath, replacementPath, plan };
|
|
1901
|
+
archiveBytes(receiptPath, Buffer.from(`${JSON.stringify(receipt)}
|
|
1902
|
+
`));
|
|
1903
|
+
syncDirectory(directory);
|
|
1904
|
+
syncDirectory(dirname2(directory));
|
|
1905
|
+
syncDirectory(selectionCacheRoot(options));
|
|
1906
|
+
if (sha256Hex(readRegularFile(archivePath, MAX_PROFILE_DOCUMENT_BYTES)) !== before.sha256 || !sameSnapshot(readSkillSessionSnapshot(replacement.sessionId, options), expected)) {
|
|
1907
|
+
throw new SkillSelectionError("SESSION_RECEIPT_CHANGED", "The session or its preservation archive changed before replacement; retained evidence must be reviewed.");
|
|
1908
|
+
}
|
|
1909
|
+
beforeCommit();
|
|
1910
|
+
assertOwned();
|
|
1911
|
+
try {
|
|
1912
|
+
atomicWrite(before.path, bytes, 384, true);
|
|
1913
|
+
atomicWrite(receiptPath, Buffer.from(`${JSON.stringify({ ...receipt, status: "applied" })}
|
|
1914
|
+
`), 384, true);
|
|
1915
|
+
} catch {
|
|
1916
|
+
throw new SkillSelectionError("SESSION_RECONCILIATION_INCOMPLETE", `Inspect sessions show and the preserved operation receipt at ${receiptPath} before any retry; replacement may already have committed.`);
|
|
1917
|
+
}
|
|
1918
|
+
return { archivePath, receiptPath, beforeSha256: before.sha256, afterSha256 };
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/lib/agent-policy-limits.ts
|
|
1923
|
+
import { basename as basename3, isAbsolute as isAbsolute3, join as join5, resolve as resolve5, sep } from "path";
|
|
1924
|
+
|
|
1925
|
+
// src/lib/codex-native-compatibility.ts
|
|
1926
|
+
var MEASURED_CODEX_CAPABILITIES = {
|
|
1927
|
+
"codex-cli 0.153.0": ["hooks"],
|
|
1928
|
+
"codex-cli 0.154.0": ["hooks"],
|
|
1929
|
+
"codex-cli 0.155.0": ["hooks"],
|
|
1930
|
+
"codex-cli 0.155.1": ["hooks"],
|
|
1931
|
+
"codex-cli 0.156.1": ["hooks"],
|
|
1932
|
+
"codex-cli 0.157.0": ["hooks"],
|
|
1933
|
+
"codex-cli 0.157.1": ["hooks"],
|
|
1934
|
+
"codex-cli 0.158.0": ["hooks"],
|
|
1935
|
+
"codex-cli 0.159.0": ["hooks"],
|
|
1936
|
+
"codex-cli 0.159.2": ["hooks", "qualified-skill-catalog"],
|
|
1937
|
+
"codex-cli 0.160.0": ["hooks", "qualified-skill-catalog", "installed-plugin-review"],
|
|
1938
|
+
"codex-cli 0.160.1": ["hooks", "qualified-skill-catalog", "installed-plugin-review"],
|
|
1939
|
+
"codex-cli 0.161.0": ["hooks", "qualified-skill-catalog", "installed-plugin-review"],
|
|
1940
|
+
"codex-cli 0.162.0": ["hooks", "qualified-skill-catalog", "installed-plugin-review"]
|
|
1941
|
+
};
|
|
1942
|
+
function supportsCodexNativeCapability(version2, capability) {
|
|
1943
|
+
return typeof version2 === "string" && Object.hasOwn(MEASURED_CODEX_CAPABILITIES, version2) && MEASURED_CODEX_CAPABILITIES[version2].includes(capability);
|
|
1944
|
+
}
|
|
1945
|
+
var SUPPORTED_CODEX_HOOK_VERSIONS = Object.freeze(Object.keys(MEASURED_CODEX_CAPABILITIES).filter((version2) => supportsCodexNativeCapability(version2, "hooks")));
|
|
1946
|
+
|
|
1947
|
+
// src/lib/claude-marketplace-entry-witness.ts
|
|
1948
|
+
import { createHash as createHash4 } from "crypto";
|
|
1949
|
+
import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
|
|
1950
|
+
|
|
1951
|
+
// src/lib/claude-settings-witness.ts
|
|
1952
|
+
import { createHash as createHash3 } from "crypto";
|
|
1953
|
+
import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, lstatSync as lstatSync2, openSync as openSync2, readSync as readSync2 } from "fs";
|
|
1954
|
+
import { basename, dirname as dirname3, isAbsolute, resolve as resolve3 } from "path";
|
|
1955
|
+
var CLAUDE_SETTINGS_WITNESS_LIMITS = Object.freeze({ bytes: 1024 * 1024, rows: 1024, depth: 32, nodes: 65536, stringCharacters: 16384, pathCharacters: 4096 });
|
|
1956
|
+
function need(value, reason) {
|
|
1957
|
+
if (!value)
|
|
1958
|
+
throw new Error(`Claude settings witness ${reason}`);
|
|
1959
|
+
}
|
|
1960
|
+
function absolutePath(value) {
|
|
1961
|
+
need(typeof value === "string" && value.length > 0 && value.length <= CLAUDE_SETTINGS_WITNESS_LIMITS.pathCharacters && !/[\x00-\x1f\x7f]/.test(value) && isAbsolute(value) && resolve3(value) === value, "requires a normalized absolute path");
|
|
1962
|
+
}
|
|
1963
|
+
var sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.uid === b.uid && a.gid === b.gid && a.nlink === b.nlink && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
1964
|
+
function parents(path) {
|
|
1965
|
+
const result = [];
|
|
1966
|
+
for (let at = dirname3(path);; at = dirname3(at)) {
|
|
1967
|
+
const stat = lstatSync2(at, { bigint: true });
|
|
1968
|
+
need(stat.isDirectory() && !stat.isSymbolicLink(), "path traverses a symlink or non-directory");
|
|
1969
|
+
result.push([at, stat]);
|
|
1970
|
+
if (at === dirname3(at))
|
|
1971
|
+
return result;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function readNativeSettingsWitnessFile(path, budget, filename = "settings.json") {
|
|
1975
|
+
absolutePath(path);
|
|
1976
|
+
need(basename(path) === filename, `requires ${filename}`);
|
|
1977
|
+
need(Number.isSafeInteger(budget.remaining) && budget.remaining >= 0, "has an invalid byte budget");
|
|
1978
|
+
const ancestors = parents(path), initial = lstatSync2(path, { bigint: true });
|
|
1979
|
+
need(initial.isFile() && !initial.isSymbolicLink() && initial.size <= BigInt(CLAUDE_SETTINGS_WITNESS_LIMITS.bytes), "is not a bounded regular file");
|
|
1980
|
+
need(initial.size <= BigInt(budget.remaining), "exceeds its aggregate byte limit");
|
|
1981
|
+
const fd = openSync2(path, constants2.O_RDONLY | constants2.O_NOFOLLOW | constants2.O_NONBLOCK);
|
|
1982
|
+
try {
|
|
1983
|
+
const opened = fstatSync2(fd, { bigint: true });
|
|
1984
|
+
need(opened.isFile() && sameFile(initial, opened), "changed during open");
|
|
1985
|
+
const bytes = Buffer.alloc(Number(opened.size) + 1);
|
|
1986
|
+
let length = 0;
|
|
1987
|
+
while (length < bytes.length) {
|
|
1988
|
+
const count = readSync2(fd, bytes, length, bytes.length - length, null);
|
|
1989
|
+
if (!count)
|
|
1990
|
+
break;
|
|
1991
|
+
length += count;
|
|
1992
|
+
}
|
|
1993
|
+
const after = fstatSync2(fd, { bigint: true }), current = lstatSync2(path, { bigint: true });
|
|
1994
|
+
need(current.isFile() && sameFile(opened, after) && sameFile(after, current) && BigInt(length) === opened.size, "changed during read");
|
|
1995
|
+
for (const [ancestor, before] of ancestors) {
|
|
1996
|
+
const now = lstatSync2(ancestor, { bigint: true });
|
|
1997
|
+
need(now.isDirectory() && before.dev === now.dev && before.ino === now.ino && before.mode === now.mode, "parent changed during read");
|
|
1998
|
+
}
|
|
1999
|
+
budget.remaining -= length;
|
|
2000
|
+
try {
|
|
2001
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length));
|
|
2002
|
+
} catch {
|
|
2003
|
+
throw new Error("Claude settings witness is not strict UTF-8");
|
|
2004
|
+
}
|
|
2005
|
+
} finally {
|
|
2006
|
+
closeSync2(fd);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function parse2(text) {
|
|
2010
|
+
let at = 0, nodes = 0;
|
|
2011
|
+
const numberPattern = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y;
|
|
2012
|
+
const space = () => {
|
|
2013
|
+
while (at < text.length && /[\x20\t\r\n]/.test(text[at]))
|
|
2014
|
+
at++;
|
|
2015
|
+
};
|
|
2016
|
+
function string() {
|
|
2017
|
+
need(text[at] === '"', "contains invalid JSON");
|
|
2018
|
+
const start = at++;
|
|
2019
|
+
while (at < text.length) {
|
|
2020
|
+
const character = text[at++];
|
|
2021
|
+
if (character === '"') {
|
|
2022
|
+
let value2;
|
|
2023
|
+
try {
|
|
2024
|
+
value2 = JSON.parse(text.slice(start, at));
|
|
2025
|
+
} catch {
|
|
2026
|
+
throw new Error("Claude settings witness contains invalid JSON string");
|
|
2027
|
+
}
|
|
2028
|
+
need(value2.length <= CLAUDE_SETTINGS_WITNESS_LIMITS.stringCharacters, "exceeds its string limit");
|
|
2029
|
+
return value2;
|
|
2030
|
+
}
|
|
2031
|
+
if (character === "\\")
|
|
2032
|
+
at++;
|
|
2033
|
+
}
|
|
2034
|
+
throw new Error("Claude settings witness contains an unterminated JSON string");
|
|
2035
|
+
}
|
|
2036
|
+
function value(depth) {
|
|
2037
|
+
need(depth <= CLAUDE_SETTINGS_WITNESS_LIMITS.depth && ++nodes <= CLAUDE_SETTINGS_WITNESS_LIMITS.nodes, "exceeds its nesting or node limit");
|
|
2038
|
+
space();
|
|
2039
|
+
const character = text[at];
|
|
2040
|
+
if (character === '"')
|
|
2041
|
+
return { kind: "string", value: string() };
|
|
2042
|
+
if (character === "{" || character === "[") {
|
|
2043
|
+
at++;
|
|
2044
|
+
space();
|
|
2045
|
+
const object = character === "{", end = object ? "}" : "]", entries = [], items = [], keys = new Set;
|
|
2046
|
+
if (text[at] !== end)
|
|
2047
|
+
while (true) {
|
|
2048
|
+
if (object) {
|
|
2049
|
+
space();
|
|
2050
|
+
const key = string();
|
|
2051
|
+
need(!keys.has(key), "contains a duplicate JSON key");
|
|
2052
|
+
keys.add(key);
|
|
2053
|
+
space();
|
|
2054
|
+
need(text[at++] === ":", "contains invalid JSON");
|
|
2055
|
+
entries.push([key, value(depth + 1)]);
|
|
2056
|
+
} else
|
|
2057
|
+
items.push(value(depth + 1));
|
|
2058
|
+
space();
|
|
2059
|
+
if (text[at] === end)
|
|
2060
|
+
break;
|
|
2061
|
+
need(text[at++] === ",", "contains invalid JSON");
|
|
2062
|
+
}
|
|
2063
|
+
at++;
|
|
2064
|
+
return object ? { kind: "object", entries } : { kind: "array", items };
|
|
2065
|
+
}
|
|
2066
|
+
for (const literal of ["true", "false", "null"])
|
|
2067
|
+
if (text.startsWith(literal, at)) {
|
|
2068
|
+
at += literal.length;
|
|
2069
|
+
return { kind: "literal", value: literal };
|
|
2070
|
+
}
|
|
2071
|
+
numberPattern.lastIndex = at;
|
|
2072
|
+
const number = numberPattern.exec(text);
|
|
2073
|
+
need(number, "contains invalid JSON");
|
|
2074
|
+
at += number[0].length;
|
|
2075
|
+
return { kind: "number", value: number[0] };
|
|
2076
|
+
}
|
|
2077
|
+
const root = value(0);
|
|
2078
|
+
space();
|
|
2079
|
+
need(at === text.length && root.kind === "object", "requires a JSON object without trailing content");
|
|
2080
|
+
need(root.entries.length <= CLAUDE_SETTINGS_WITNESS_LIMITS.rows, "exceeds its settings field limit");
|
|
2081
|
+
return root;
|
|
2082
|
+
}
|
|
2083
|
+
function parseNativeJsonObject(text) {
|
|
2084
|
+
need(typeof text === "string" && Buffer.byteLength(text) <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes, "exceeds its byte limit");
|
|
2085
|
+
return parse2(text);
|
|
2086
|
+
}
|
|
2087
|
+
var BOOLEAN_PREFERENCES = new Set([
|
|
2088
|
+
"autoScrollEnabled",
|
|
2089
|
+
"axScreenReader",
|
|
2090
|
+
"emojiCompletionEnabled",
|
|
2091
|
+
"prefersReducedMotion",
|
|
2092
|
+
"showTurnDuration",
|
|
2093
|
+
"spinnerTipsEnabled",
|
|
2094
|
+
"syntaxHighlightingDisabled",
|
|
2095
|
+
"terminalProgressBarEnabled",
|
|
2096
|
+
"terminalTitleFromRename",
|
|
2097
|
+
"verbose",
|
|
2098
|
+
"wheelScrollAccelerationEnabled"
|
|
2099
|
+
]);
|
|
2100
|
+
var ENUM_PREFERENCES = Object.freeze({
|
|
2101
|
+
editorMode: ["normal", "vim"],
|
|
2102
|
+
tui: ["default", "fullscreen"],
|
|
2103
|
+
viewMode: ["default", "verbose", "focus"]
|
|
2104
|
+
});
|
|
2105
|
+
var BUILTIN_MODEL_SELECTIONS = new Set([
|
|
2106
|
+
"default",
|
|
2107
|
+
"best",
|
|
2108
|
+
"fable",
|
|
2109
|
+
"fable[1m]",
|
|
2110
|
+
"sonnet",
|
|
2111
|
+
"sonnet[1m]",
|
|
2112
|
+
"opus",
|
|
2113
|
+
"opus[1m]",
|
|
2114
|
+
"haiku",
|
|
2115
|
+
"opusplan",
|
|
2116
|
+
"claude-fable-5-1",
|
|
2117
|
+
"claude-fable-5",
|
|
2118
|
+
"claude-fable-5[1m]",
|
|
2119
|
+
"claude-opus-5",
|
|
2120
|
+
"claude-sonnet-5",
|
|
2121
|
+
"claude-haiku-4-5-20251001",
|
|
2122
|
+
"claude-opus-4-6",
|
|
2123
|
+
"claude-sonnet-4-5",
|
|
2124
|
+
"claude-sonnet-4-5-20250929",
|
|
2125
|
+
"claude-opus-4-8",
|
|
2126
|
+
"claude-opus-4-8[1m]",
|
|
2127
|
+
"claude-opus-4-7",
|
|
2128
|
+
"claude-sonnet-4-6",
|
|
2129
|
+
"claude-opus-4-5-20251101",
|
|
2130
|
+
"claude-opus-4-5",
|
|
2131
|
+
"claude-haiku-4-5",
|
|
2132
|
+
"claude-fable-5-1[1m]",
|
|
2133
|
+
"claude-opus-5[1m]",
|
|
2134
|
+
"claude-opus-4-7[1m]",
|
|
2135
|
+
"claude-opus-4-6[1m]",
|
|
2136
|
+
"claude-sonnet-4-6[1m]"
|
|
2137
|
+
]);
|
|
2138
|
+
var CLAUDE_BUILTIN_THEMES = Object.freeze(["auto", "dark", "light", "dark-daltonized", "light-daltonized", "dark-ansi", "light-ansi"]);
|
|
2139
|
+
var BUILTIN_THEMES = new Set(CLAUDE_BUILTIN_THEMES);
|
|
2140
|
+
function canonical(value) {
|
|
2141
|
+
if (value.kind === "object")
|
|
2142
|
+
return { kind: "object", entries: value.entries.map(([key, child]) => [key, canonical(child)]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) };
|
|
2143
|
+
if (value.kind === "array")
|
|
2144
|
+
return { kind: "array", items: value.items.map(canonical) };
|
|
2145
|
+
return value;
|
|
2146
|
+
}
|
|
2147
|
+
var CLAUDE_COORDINATED_HOOK_EVENTS = ["PreToolUse", "PostToolUse", "Stop", "Notification", "SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart"];
|
|
2148
|
+
function assertClaudeHookEventsReplacement(before, replacement, events) {
|
|
2149
|
+
need(Array.isArray(events) && events.length > 0 && events.length <= CLAUDE_COORDINATED_HOOK_EVENTS.length && new Set(events).size === events.length && events.every((event) => CLAUDE_COORDINATED_HOOK_EVENTS.includes(event)), "requires explicit supported hook events");
|
|
2150
|
+
const label = events.join(",");
|
|
2151
|
+
function bounded(text) {
|
|
2152
|
+
need(typeof text === "string" && Buffer.byteLength(text) <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes, `${label} update exceeds its byte limit`);
|
|
2153
|
+
return parse2(text);
|
|
2154
|
+
}
|
|
2155
|
+
const original = bounded(before ?? "{}"), next = bounded(replacement);
|
|
2156
|
+
const hadHooks = original.entries.some(([key]) => key === "hooks");
|
|
2157
|
+
function withoutOwnedEvents(value) {
|
|
2158
|
+
const hooks = value.entries.find(([key]) => key === "hooks")?.[1];
|
|
2159
|
+
if (hooks !== undefined) {
|
|
2160
|
+
need(hooks.kind === "object", `${label} update requires an object of hooks`);
|
|
2161
|
+
for (const event of events) {
|
|
2162
|
+
const selected = hooks.entries.find(([key]) => key === event)?.[1];
|
|
2163
|
+
need(selected === undefined || selected.kind === "array", `${event} update requires a ${event} array`);
|
|
2164
|
+
}
|
|
2165
|
+
hooks.entries = hooks.entries.filter(([key]) => !events.includes(key));
|
|
2166
|
+
if (!hadHooks && hooks.entries.length === 0)
|
|
2167
|
+
value.entries = value.entries.filter(([key]) => key !== "hooks");
|
|
2168
|
+
}
|
|
2169
|
+
return canonical(value);
|
|
2170
|
+
}
|
|
2171
|
+
need(JSON.stringify(withoutOwnedEvents(original)) === JSON.stringify(withoutOwnedEvents(next)), `${label} update changed settings outside hooks.${label}`);
|
|
2172
|
+
}
|
|
2173
|
+
var PERSISTED_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh"]);
|
|
2174
|
+
function inferencePreference(key, child, version2) {
|
|
2175
|
+
if (key === "skipDangerousModePermissionPrompt") {
|
|
2176
|
+
need(child.kind === "literal" && (child.value === "true" || child.value === "false"), "contains an invalid dialog acknowledgment");
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
if (key === "effortLevel") {
|
|
2180
|
+
need(child.kind === "string" && PERSISTED_EFFORT_LEVELS.has(child.value), "contains an invalid persisted effort level");
|
|
2181
|
+
return true;
|
|
2182
|
+
}
|
|
2183
|
+
if (key !== "modelSettings")
|
|
2184
|
+
return false;
|
|
2185
|
+
need(child.kind === "object", "requires an object of model preferences");
|
|
2186
|
+
child.entries = child.entries.filter(([model, preferences]) => {
|
|
2187
|
+
if (version2 === 2 && !BUILTIN_MODEL_SELECTIONS.has(model) || preferences.kind !== "object" || preferences.entries.length !== 1 || preferences.entries[0][0] !== "effortLevel")
|
|
2188
|
+
return true;
|
|
2189
|
+
const effort = preferences.entries[0][1];
|
|
2190
|
+
need(effort.kind === "string" && PERSISTED_EFFORT_LEVELS.has(effort.value), "contains an invalid persisted model effort level");
|
|
2191
|
+
return false;
|
|
2192
|
+
});
|
|
2193
|
+
return child.entries.length === 0;
|
|
2194
|
+
}
|
|
2195
|
+
function settingsDigest(text, version2 = 1) {
|
|
2196
|
+
const value = parse2(text);
|
|
2197
|
+
value.entries = value.entries.filter(([key, child]) => {
|
|
2198
|
+
if (version2 === 4 && key === "theme" && child.kind === "string" && BUILTIN_THEMES.has(child.value))
|
|
2199
|
+
return false;
|
|
2200
|
+
if (version2 !== 1 && inferencePreference(key, child, version2 === 4 ? 3 : version2))
|
|
2201
|
+
return false;
|
|
2202
|
+
if (key === "model") {
|
|
2203
|
+
need(child.kind === "string", "contains an invalid model selection");
|
|
2204
|
+
return !BUILTIN_MODEL_SELECTIONS.has(child.value);
|
|
2205
|
+
}
|
|
2206
|
+
if (BOOLEAN_PREFERENCES.has(key)) {
|
|
2207
|
+
need(child.kind === "literal" && (child.value === "true" || child.value === "false"), "contains an invalid display preference");
|
|
2208
|
+
return false;
|
|
2209
|
+
}
|
|
2210
|
+
if (Object.hasOwn(ENUM_PREFERENCES, key)) {
|
|
2211
|
+
need(child.kind === "string" && ENUM_PREFERENCES[key].includes(child.value), "contains an invalid display preference");
|
|
2212
|
+
return false;
|
|
2213
|
+
}
|
|
2214
|
+
return true;
|
|
2215
|
+
});
|
|
2216
|
+
return createHash3("sha256").update(`hasna.skills.claude-settings.v${version2}\x00`).update(JSON.stringify(canonical(value))).digest("hex");
|
|
2217
|
+
}
|
|
2218
|
+
function hashClaudeSettingsReplacement(text, budget) {
|
|
2219
|
+
need(typeof text === "string", "requires settings text");
|
|
2220
|
+
const bytes = Buffer.byteLength(text);
|
|
2221
|
+
need(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
|
|
2222
|
+
budget.remaining -= bytes;
|
|
2223
|
+
return settingsDigest(text);
|
|
2224
|
+
}
|
|
2225
|
+
function captureClaudeSettings(path, budget = { remaining: 256 * 1024 * 1024 }) {
|
|
2226
|
+
return { path, hashMode: "claude-settings-v1", sha256: settingsDigest(readNativeSettingsWitnessFile(path, budget)) };
|
|
2227
|
+
}
|
|
2228
|
+
function captureClaudeSettingsV2(path, budget = { remaining: 256 * 1024 * 1024 }) {
|
|
2229
|
+
return { path, hashMode: "claude-settings-v2", sha256: settingsDigest(readNativeSettingsWitnessFile(path, budget), 2) };
|
|
2230
|
+
}
|
|
2231
|
+
function hashClaudeSettingsReplacementV2(text, budget) {
|
|
2232
|
+
need(typeof text === "string", "requires settings text");
|
|
2233
|
+
const bytes = Buffer.byteLength(text);
|
|
2234
|
+
need(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
|
|
2235
|
+
budget.remaining -= bytes;
|
|
2236
|
+
return settingsDigest(text, 2);
|
|
2237
|
+
}
|
|
2238
|
+
function captureClaudeSettingsV3(path, budget = { remaining: 256 * 1024 * 1024 }) {
|
|
2239
|
+
return { path, hashMode: "claude-settings-v3", sha256: settingsDigest(readNativeSettingsWitnessFile(path, budget), 3) };
|
|
2240
|
+
}
|
|
2241
|
+
function hashClaudeSettingsReplacementV3(text, budget) {
|
|
2242
|
+
need(typeof text === "string", "requires settings text");
|
|
2243
|
+
const bytes = Buffer.byteLength(text);
|
|
2244
|
+
need(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
|
|
2245
|
+
budget.remaining -= bytes;
|
|
2246
|
+
return settingsDigest(text, 3);
|
|
2247
|
+
}
|
|
2248
|
+
function upgradeClaudeSettingsWitness(previous, reviewedSettingsPath) {
|
|
2249
|
+
need(previous && Object.keys(previous).every((key) => ["path", "hashMode", "sha256"].includes(key)) && (previous.hashMode === undefined || previous.hashMode === "bytes" || previous.hashMode === "claude-settings-v1" || previous.hashMode === "claude-settings-v2") && typeof previous.sha256 === "string" && /^[a-f0-9]{64}$/.test(previous.sha256), "requires an exact legacy settings witness");
|
|
2250
|
+
const budget = { remaining: 2 * CLAUDE_SETTINGS_WITNESS_LIMITS.bytes };
|
|
2251
|
+
const before = readNativeSettingsWitnessFile(reviewedSettingsPath, budget), current = readNativeSettingsWitnessFile(previous.path, budget);
|
|
2252
|
+
const beforeDigest = previous.hashMode === undefined || previous.hashMode === "bytes" ? createHash3("sha256").update(before).digest("hex") : settingsDigest(before, previous.hashMode === "claude-settings-v1" ? 1 : 2);
|
|
2253
|
+
need(beforeDigest === previous.sha256, "reviewed preimage does not match the legacy witness");
|
|
2254
|
+
const sha256 = settingsDigest(current, 3);
|
|
2255
|
+
need(settingsDigest(before, 3) === sha256, "non-preference settings changed; explicit discovery review required");
|
|
2256
|
+
return { path: previous.path, hashMode: "claude-settings-v3", sha256 };
|
|
2257
|
+
}
|
|
2258
|
+
function captureClaudeSettingsV4(path, budget = { remaining: 256 * 1024 * 1024 }) {
|
|
2259
|
+
return { path, hashMode: "claude-settings-v4", sha256: settingsDigest(readNativeSettingsWitnessFile(path, budget), 4) };
|
|
2260
|
+
}
|
|
2261
|
+
function hashClaudeSettingsReplacementV4(text, budget) {
|
|
2262
|
+
need(typeof text === "string", "requires settings text");
|
|
2263
|
+
const bytes = Buffer.byteLength(text);
|
|
2264
|
+
need(bytes <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes && Number.isSafeInteger(budget.remaining) && budget.remaining >= bytes, "exceeds its byte limit");
|
|
2265
|
+
budget.remaining -= bytes;
|
|
2266
|
+
return settingsDigest(text, 4);
|
|
2267
|
+
}
|
|
2268
|
+
function upgradeClaudeSettingsWitnessV4(previous, reviewedSettingsPath) {
|
|
2269
|
+
need(previous && Object.keys(previous).every((key) => ["path", "hashMode", "sha256"].includes(key)) && (previous.hashMode === undefined || previous.hashMode === "bytes" || previous.hashMode === "claude-settings-v1" || previous.hashMode === "claude-settings-v2" || previous.hashMode === "claude-settings-v3") && typeof previous.sha256 === "string" && /^[a-f0-9]{64}$/.test(previous.sha256), "requires an exact prior settings witness");
|
|
2270
|
+
const budget = { remaining: 2 * CLAUDE_SETTINGS_WITNESS_LIMITS.bytes };
|
|
2271
|
+
const before = readNativeSettingsWitnessFile(reviewedSettingsPath, budget), current = readNativeSettingsWitnessFile(previous.path, budget);
|
|
2272
|
+
const beforeDigest = previous.hashMode === undefined || previous.hashMode === "bytes" ? createHash3("sha256").update(before).digest("hex") : settingsDigest(before, previous.hashMode === "claude-settings-v1" ? 1 : previous.hashMode === "claude-settings-v2" ? 2 : 3);
|
|
2273
|
+
need(beforeDigest === previous.sha256, "reviewed preimage does not match the prior witness");
|
|
2274
|
+
const sha256 = settingsDigest(current, 4);
|
|
2275
|
+
need(settingsDigest(before, 4) === sha256, "non-preference settings changed; explicit discovery review required");
|
|
2276
|
+
return { path: previous.path, hashMode: "claude-settings-v4", sha256 };
|
|
2277
|
+
}
|
|
2278
|
+
function hashNativeJsonControls(text, excludedMetadata) {
|
|
2279
|
+
need(Buffer.byteLength(text) <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes, "exceeds its byte limit");
|
|
2280
|
+
const value = parse2(text);
|
|
2281
|
+
value.entries = value.entries.filter(([key, child]) => {
|
|
2282
|
+
if (key !== excludedMetadata)
|
|
2283
|
+
return true;
|
|
2284
|
+
need(child.kind === "string" && child.value.length <= 128, "has invalid version metadata");
|
|
2285
|
+
return false;
|
|
2286
|
+
});
|
|
2287
|
+
return createHash3("sha256").update("hasna.skills.native-json-controls.v1\x00").update(JSON.stringify(canonical(value))).digest("hex");
|
|
2288
|
+
}
|
|
2289
|
+
function hashSumiNativeJsonControls(text) {
|
|
2290
|
+
need(Buffer.byteLength(text) <= CLAUDE_SETTINGS_WITNESS_LIMITS.bytes, "exceeds its byte limit");
|
|
2291
|
+
const value = parse2(text);
|
|
2292
|
+
value.entries = value.entries.filter(([key, child]) => {
|
|
2293
|
+
if (key === "$schema" || key === "username") {
|
|
2294
|
+
need(child.kind === "string", "has invalid Sumi display metadata");
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
if (key !== "experimental")
|
|
2298
|
+
return true;
|
|
2299
|
+
need(child.kind === "object", "requires Sumi experimental controls to be an object");
|
|
2300
|
+
child.entries = child.entries.filter(([name, option]) => {
|
|
2301
|
+
if (name !== "statusline" && name !== "compact_tools")
|
|
2302
|
+
return true;
|
|
2303
|
+
need(option.kind === "literal" && (option.value === "true" || option.value === "false"), "has invalid Sumi display preference");
|
|
2304
|
+
return false;
|
|
2305
|
+
});
|
|
2306
|
+
return child.entries.length > 0;
|
|
2307
|
+
});
|
|
2308
|
+
return createHash3("sha256").update("hasna.skills.sumi-settings.v1\x00").update(JSON.stringify(value)).digest("hex");
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
// src/lib/claude-marketplace-entry-witness.ts
|
|
2312
|
+
var CLAUDE_MARKETPLACE_ENTRY_HASH_MODE = "claude-marketplace-entry-v1";
|
|
2313
|
+
var DOMAIN = "hasna.skills.claude-marketplace-entry.v1\x00";
|
|
2314
|
+
var PATH_CHARACTERS = 4096;
|
|
2315
|
+
var NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
2316
|
+
function need2(value, reason) {
|
|
2317
|
+
if (!value)
|
|
2318
|
+
throw new Error(`Claude marketplace entry witness ${reason}`);
|
|
2319
|
+
}
|
|
2320
|
+
var bounded = (key) => key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
2321
|
+
var quote = (text) => JSON.stringify(text).replace(/[^\x20-\x7e]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
2322
|
+
var child = (path, key) => path + (/^[A-Za-z0-9_$-]{1,64}$/.test(key) ? `.${key}` : `[${JSON.stringify(bounded(key))}]`);
|
|
2323
|
+
function unknownKey(selector, path, key) {
|
|
2324
|
+
throw new Error(path === null ? `${CLAUDE_MARKETPLACE_ENTRY_HASH_MODE}: unknown top-level key ${quote(bounded(key))} in ${selector.marketplace}` : `${CLAUDE_MARKETPLACE_ENTRY_HASH_MODE}: unknown key ${quote(child(path, key))} in ${selector.marketplace}`);
|
|
2325
|
+
}
|
|
2326
|
+
function knownKeys(object, allowed, selector, path) {
|
|
2327
|
+
for (const [key] of object.entries)
|
|
2328
|
+
if (!allowed.has(key))
|
|
2329
|
+
unknownKey(selector, path, key);
|
|
2330
|
+
}
|
|
2331
|
+
var reason = (error) => String(error?.message ?? error).replace(/^Claude settings witness /, "");
|
|
2332
|
+
var MARKETPLACE_KEYS = new Set(["$schema", "name", "owner", "plugins", "description", "version", "metadata", "forceRemoveDeletedPlugins", "allowCrossMarketplaceDependenciesOn", "renames"]);
|
|
2333
|
+
var MARKETPLACE_METADATA_KEYS = new Set(["description", "version", "pluginRoot"]);
|
|
2334
|
+
var ENTRY_BOUND_KEYS = new Set([
|
|
2335
|
+
"name",
|
|
2336
|
+
"source",
|
|
2337
|
+
"strict",
|
|
2338
|
+
"defaultEnabled",
|
|
2339
|
+
"dependencies",
|
|
2340
|
+
"relevance",
|
|
2341
|
+
"headers",
|
|
2342
|
+
"headersHelper",
|
|
2343
|
+
"settings",
|
|
2344
|
+
"userConfig",
|
|
2345
|
+
"types",
|
|
2346
|
+
"channels",
|
|
2347
|
+
"skills",
|
|
2348
|
+
"commands",
|
|
2349
|
+
"agents",
|
|
2350
|
+
"hooks",
|
|
2351
|
+
"mcpServers",
|
|
2352
|
+
"lspServers",
|
|
2353
|
+
"outputStyles",
|
|
2354
|
+
"workflows",
|
|
2355
|
+
"experimental",
|
|
2356
|
+
"themes",
|
|
2357
|
+
"monitors"
|
|
2358
|
+
]);
|
|
2359
|
+
var ENTRY_OMITTED_KEYS = new Set(["$schema", "description", "version", "author", "homepage", "repository", "license", "keywords", "category", "tags", "displayName", "metadata"]);
|
|
2360
|
+
var EXPERIMENTAL_KEYS = new Set(["themes", "monitors", "evals"]);
|
|
2361
|
+
var SOURCE_KEYS = Object.freeze({
|
|
2362
|
+
github: ["source", "repo", "ref", "sha"],
|
|
2363
|
+
url: ["source", "url", "ref", "sha", "path"],
|
|
2364
|
+
"git-subdir": ["source", "url", "path", "ref", "sha"],
|
|
2365
|
+
npm: ["source", "package", "version", "registry"],
|
|
2366
|
+
archive: ["source", "url", "sha256"],
|
|
2367
|
+
command: ["source", "command", "timeout", "mode"]
|
|
2368
|
+
});
|
|
2369
|
+
var field = (object, key) => object.entries.find(([name]) => name === key)?.[1];
|
|
2370
|
+
var isString = (value) => value?.kind === "string";
|
|
2371
|
+
var isBoolean = (value) => value?.kind === "literal" && (value.value === "true" || value.value === "false");
|
|
2372
|
+
var stringArray = (value) => value?.kind === "array" && value.items.every(isString);
|
|
2373
|
+
var stringObject = (value) => value?.kind === "object" && value.entries.every(([, item]) => isString(item));
|
|
2374
|
+
function claudeMarketplaceEntrySourceValid(source) {
|
|
2375
|
+
const path = source?.path;
|
|
2376
|
+
return typeof path === "string" && path.length > 0 && path.length <= PATH_CHARACTERS && !/[\x00-\x1f\x7f]/.test(path) && isAbsolute2(path) && resolve4(path) === path && basename2(path) === "marketplace.json" && basename2(dirname4(path)) === ".claude-plugin" && typeof source.marketplace === "string" && NAME.test(source.marketplace) && !source.marketplace.includes("..") && typeof source.plugin === "string" && NAME.test(source.plugin);
|
|
2377
|
+
}
|
|
2378
|
+
function canonical2(value) {
|
|
2379
|
+
if (value.kind === "string")
|
|
2380
|
+
return JSON.stringify(value.value);
|
|
2381
|
+
if (value.kind === "number" || value.kind === "literal")
|
|
2382
|
+
return value.value;
|
|
2383
|
+
if (value.kind === "array")
|
|
2384
|
+
return "[" + value.items.map(canonical2).join(",") + "]";
|
|
2385
|
+
return "{" + [...value.entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => JSON.stringify(key) + ":" + canonical2(item)).join(",") + "}";
|
|
2386
|
+
}
|
|
2387
|
+
function validateMarketplace(root, selector) {
|
|
2388
|
+
knownKeys(root, MARKETPLACE_KEYS, selector, null);
|
|
2389
|
+
const name = field(root, "name");
|
|
2390
|
+
need2(isString(name) && name.value === selector.marketplace, "marketplace name does not match the reviewed binding");
|
|
2391
|
+
for (const key of ["$schema", "description", "version"]) {
|
|
2392
|
+
const value = field(root, key);
|
|
2393
|
+
need2(value === undefined || isString(value), `has an invalid marketplace ${key}`);
|
|
2394
|
+
}
|
|
2395
|
+
const owner = field(root, "owner");
|
|
2396
|
+
need2(owner === undefined || owner.kind === "object", "has an invalid marketplace owner");
|
|
2397
|
+
const metadata = field(root, "metadata");
|
|
2398
|
+
if (metadata !== undefined) {
|
|
2399
|
+
need2(metadata.kind === "object", "has an invalid marketplace metadata object");
|
|
2400
|
+
knownKeys(metadata, MARKETPLACE_METADATA_KEYS, selector, "metadata");
|
|
2401
|
+
for (const [key, value] of metadata.entries)
|
|
2402
|
+
need2(isString(value), `has an invalid marketplace metadata.${key}`);
|
|
2403
|
+
}
|
|
2404
|
+
const remove = field(root, "forceRemoveDeletedPlugins");
|
|
2405
|
+
need2(remove === undefined || isBoolean(remove), "has an invalid forceRemoveDeletedPlugins value");
|
|
2406
|
+
const cross = field(root, "allowCrossMarketplaceDependenciesOn");
|
|
2407
|
+
need2(cross === undefined || stringArray(cross), "has an invalid allowCrossMarketplaceDependenciesOn value");
|
|
2408
|
+
const renames = field(root, "renames");
|
|
2409
|
+
if (renames !== undefined) {
|
|
2410
|
+
need2(renames.kind === "object" && renames.entries.every(([, target]) => isString(target) || target.kind === "literal" && target.value === "null"), "has an invalid renames map");
|
|
2411
|
+
need2(!renames.entries.some(([from]) => from.toLowerCase() === selector.plugin.toLowerCase()), "plugin id is redirected by the marketplace renames map");
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
function validateSource(source, selector, path) {
|
|
2415
|
+
if (isString(source)) {
|
|
2416
|
+
need2(source.value.length > 0, "has an empty plugin source");
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
need2(source?.kind === "object", "has an invalid plugin source");
|
|
2420
|
+
const type = field(source, "source");
|
|
2421
|
+
need2(isString(type) && Object.hasOwn(SOURCE_KEYS, type.value), "has an unreviewed plugin source type");
|
|
2422
|
+
knownKeys(source, new Set(SOURCE_KEYS[type.value]), selector, path);
|
|
2423
|
+
}
|
|
2424
|
+
function projectEntry(entry, selector) {
|
|
2425
|
+
const path = `plugins[${selector.plugin}]`;
|
|
2426
|
+
knownKeys(entry, { has: (key) => ENTRY_BOUND_KEYS.has(key) || ENTRY_OMITTED_KEYS.has(key) }, selector, path);
|
|
2427
|
+
const name = field(entry, "name");
|
|
2428
|
+
need2(isString(name) && name.value === selector.plugin, "selected entry name does not match");
|
|
2429
|
+
validateSource(field(entry, "source"), selector, `${path}.source`);
|
|
2430
|
+
for (const key of ["strict", "defaultEnabled"]) {
|
|
2431
|
+
const value = field(entry, key);
|
|
2432
|
+
need2(value === undefined || isBoolean(value), `has an invalid entry ${key}`);
|
|
2433
|
+
}
|
|
2434
|
+
const headersHelper = field(entry, "headersHelper");
|
|
2435
|
+
need2(headersHelper === undefined || isString(headersHelper), "has an invalid entry headersHelper");
|
|
2436
|
+
const experimental = field(entry, "experimental");
|
|
2437
|
+
if (experimental !== undefined) {
|
|
2438
|
+
need2(experimental.kind === "object", "has an invalid entry experimental object");
|
|
2439
|
+
knownKeys(experimental, EXPERIMENTAL_KEYS, selector, `${path}.experimental`);
|
|
2440
|
+
}
|
|
2441
|
+
for (const key of ["$schema", "description", "version", "homepage", "license", "category", "displayName"]) {
|
|
2442
|
+
const value = field(entry, key);
|
|
2443
|
+
need2(value === undefined || isString(value), `has an invalid entry ${key}`);
|
|
2444
|
+
}
|
|
2445
|
+
for (const [key, keys] of [["author", new Set(["name", "email", "url"])], ["repository", new Set(["type", "url", "directory"])]]) {
|
|
2446
|
+
const value = field(entry, key);
|
|
2447
|
+
if (value?.kind === "object")
|
|
2448
|
+
knownKeys(value, keys, selector, `${path}.${key}`);
|
|
2449
|
+
need2(value === undefined || isString(value) || stringObject(value), `has an invalid entry ${key}`);
|
|
2450
|
+
}
|
|
2451
|
+
for (const key of ["keywords", "tags"]) {
|
|
2452
|
+
const value = field(entry, key);
|
|
2453
|
+
need2(value === undefined || stringArray(value), `has an invalid entry ${key}`);
|
|
2454
|
+
}
|
|
2455
|
+
const metadata = field(entry, "metadata");
|
|
2456
|
+
need2(metadata === undefined || metadata.kind === "object", "has an invalid entry metadata object");
|
|
2457
|
+
return { kind: "object", entries: entry.entries.filter(([key]) => ENTRY_BOUND_KEYS.has(key)) };
|
|
2458
|
+
}
|
|
2459
|
+
function hashClaudeMarketplaceEntry(text, selector) {
|
|
2460
|
+
need2(selector && typeof selector.marketplace === "string" && NAME.test(selector.marketplace) && !selector.marketplace.includes("..") && typeof selector.plugin === "string" && NAME.test(selector.plugin), "requires an exact marketplace and plugin name");
|
|
2461
|
+
let root;
|
|
2462
|
+
try {
|
|
2463
|
+
root = parseNativeJsonObject(text);
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
throw new Error(`Claude marketplace entry witness cannot parse marketplace.json: ${reason(error)}`);
|
|
2466
|
+
}
|
|
2467
|
+
validateMarketplace(root, selector);
|
|
2468
|
+
const plugins = field(root, "plugins");
|
|
2469
|
+
need2(plugins?.kind === "array", "requires a plugins array");
|
|
2470
|
+
const entryName = (item) => {
|
|
2471
|
+
if (item.kind !== "object")
|
|
2472
|
+
return;
|
|
2473
|
+
const name = field(item, "name");
|
|
2474
|
+
return isString(name) ? name.value : undefined;
|
|
2475
|
+
};
|
|
2476
|
+
const exact = plugins.items.filter((item) => entryName(item) === selector.plugin);
|
|
2477
|
+
const folded = plugins.items.filter((item) => entryName(item)?.toLowerCase() === selector.plugin.toLowerCase());
|
|
2478
|
+
need2(exact.length > 0, "selected plugin entry is missing");
|
|
2479
|
+
need2(exact.length === 1 && folded.length === 1, "selected plugin entry is duplicated or ambiguous");
|
|
2480
|
+
const entry = projectEntry(exact[0], selector);
|
|
2481
|
+
const metadata = field(root, "metadata"), pluginRoot = metadata?.kind === "object" ? field(metadata, "pluginRoot") : undefined;
|
|
2482
|
+
const nullValue = { kind: "literal", value: "null" };
|
|
2483
|
+
const marketplace = { kind: "object", entries: [
|
|
2484
|
+
["name", field(root, "name")],
|
|
2485
|
+
["pluginRoot", pluginRoot ?? nullValue],
|
|
2486
|
+
["allowCrossMarketplaceDependenciesOn", field(root, "allowCrossMarketplaceDependenciesOn") ?? nullValue]
|
|
2487
|
+
] };
|
|
2488
|
+
const projection = { kind: "object", entries: [["marketplace", marketplace], ["plugin", entry]] };
|
|
2489
|
+
return createHash4("sha256").update(DOMAIN).update(canonical2(projection)).digest("hex");
|
|
2490
|
+
}
|
|
2491
|
+
function captureClaudeMarketplaceEntry(path, marketplace, plugin, budget = { remaining: 256 * 1024 * 1024 }) {
|
|
2492
|
+
need2(claudeMarketplaceEntrySourceValid({ path, marketplace, plugin }), "requires a normalized absolute .claude-plugin/marketplace.json path and exact names");
|
|
2493
|
+
let text;
|
|
2494
|
+
try {
|
|
2495
|
+
text = readNativeSettingsWitnessFile(path, budget, "marketplace.json");
|
|
2496
|
+
} catch (error) {
|
|
2497
|
+
throw new Error(`Claude marketplace entry witness cannot read marketplace.json: ${reason(error)}`);
|
|
2498
|
+
}
|
|
2499
|
+
return { path, hashMode: CLAUDE_MARKETPLACE_ENTRY_HASH_MODE, marketplace, plugin, sha256: hashClaudeMarketplaceEntry(text, { marketplace, plugin }) };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
// src/lib/agent-policy-limits.ts
|
|
2503
|
+
var AGENT_POLICY_LIMITS = Object.freeze({ bytes: 1024 * 1024, agents: 16, discoverySources: 2048, discoveryRawSourceBytes: 64 * 1024 * 1024, discoveryRawTotalBytes: 256 * 1024 * 1024, discoveryPathLinks: 40, discoveryPathSteps: 256, discoveryPathSourceMetadataBytes: 64 * 1024, discoveryPathTotalMetadataBytes: 8 * 1024 * 1024, discoveryRoots: 512, discoveryDirectories: 64, discoveryDirectoryEntries: 20000, discoveryDirectoryBytes: 8 * 1024 * 1024, builtinNames: 2048, rootAliases: 2, fields: 64, pathCharacters: 4096 });
|
|
2504
|
+
function object(value) {
|
|
2505
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
2506
|
+
}
|
|
2507
|
+
function requireBound(value) {
|
|
2508
|
+
if (!value)
|
|
2509
|
+
throw new Error("Agent policy collection bounds are invalid");
|
|
2510
|
+
}
|
|
2511
|
+
function array(value, maximum) {
|
|
2512
|
+
requireBound(Array.isArray(value) && value.length <= maximum);
|
|
2513
|
+
return value;
|
|
2514
|
+
}
|
|
2515
|
+
function record(value, maximum) {
|
|
2516
|
+
requireBound(object(value) && Object.keys(value).length <= maximum);
|
|
2517
|
+
return value;
|
|
2518
|
+
}
|
|
2519
|
+
function text(value, maximum = AGENT_POLICY_LIMITS.pathCharacters) {
|
|
2520
|
+
requireBound(typeof value === "string" && value.length > 0 && value.length <= maximum && !value.includes("\x00"));
|
|
2521
|
+
}
|
|
2522
|
+
function assertAgentPolicyCollections(policy) {
|
|
2523
|
+
if (policy.bridge === undefined)
|
|
2524
|
+
return;
|
|
2525
|
+
const bridge = record(policy.bridge, 64);
|
|
2526
|
+
if (bridge.agents !== undefined)
|
|
2527
|
+
for (const agent of array(bridge.agents, AGENT_POLICY_LIMITS.agents))
|
|
2528
|
+
text(agent, 128);
|
|
2529
|
+
for (const key of ["commands", "profiles", "supervisors"])
|
|
2530
|
+
if (bridge[key] !== undefined) {
|
|
2531
|
+
const entries = record(bridge[key], AGENT_POLICY_LIMITS.agents);
|
|
2532
|
+
for (const [agent, value] of Object.entries(entries)) {
|
|
2533
|
+
text(agent, 128);
|
|
2534
|
+
if (key === "supervisors") {
|
|
2535
|
+
requireBound(object(value));
|
|
2536
|
+
text(value.path);
|
|
2537
|
+
text(value.runtime);
|
|
2538
|
+
text(value.sha256, 64);
|
|
2539
|
+
} else
|
|
2540
|
+
text(value, key === "profiles" ? 128 : AGENT_POLICY_LIMITS.pathCharacters);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
if (bridge.rootAliases !== undefined)
|
|
2544
|
+
for (const alias of array(bridge.rootAliases, AGENT_POLICY_LIMITS.rootAliases)) {
|
|
2545
|
+
requireBound(object(alias));
|
|
2546
|
+
for (const key of ["agent", "home", "alias", "target", "link", "aliasIdentity", "targetIdentity"])
|
|
2547
|
+
text(alias[key]);
|
|
2548
|
+
}
|
|
2549
|
+
if (bridge.codexPluginSkills !== undefined)
|
|
2550
|
+
for (const control of array(bridge.codexPluginSkills, 4096)) {
|
|
2551
|
+
requireBound(object(control) && Object.keys(control).every((key) => ["name", "pluginId", "namespace", "pluginParent", "manifestSha256", "appSha256", "mcpSha256", "remotePluginId", "skillsOnly"].includes(key)));
|
|
2552
|
+
text(control.name, 129);
|
|
2553
|
+
text(control.pluginId, 1024);
|
|
2554
|
+
text(control.namespace, 64);
|
|
2555
|
+
text(control.pluginParent);
|
|
2556
|
+
requireBound(isAbsolute3(control.pluginParent) && resolve5(control.pluginParent) === control.pluginParent && /^[a-f0-9]{64}$/.test(control.manifestSha256));
|
|
2557
|
+
if (control.remotePluginId !== undefined)
|
|
2558
|
+
requireBound(typeof control.remotePluginId === "string" && /^[A-Za-z0-9_~-]{1,1024}$/.test(control.remotePluginId));
|
|
2559
|
+
if (control.skillsOnly !== undefined)
|
|
2560
|
+
requireBound(control.skillsOnly === true && control.appSha256 === undefined && control.mcpSha256 === undefined);
|
|
2561
|
+
if (control.mcpSha256 !== undefined)
|
|
2562
|
+
requireBound(typeof control.mcpSha256 === "string" && /^[a-f0-9]{64}$/.test(control.mcpSha256));
|
|
2563
|
+
if (control.appSha256 !== undefined)
|
|
2564
|
+
requireBound(typeof control.appSha256 === "string" && /^[a-f0-9]{64}$/.test(control.appSha256));
|
|
2565
|
+
}
|
|
2566
|
+
if (bridge.codexInactivePlugins !== undefined) {
|
|
2567
|
+
const controls = array(bridge.codexInactivePlugins, 4096), ids = new Set;
|
|
2568
|
+
requireBound(controls.length === 0 || object(bridge.codexPluginSkillReview) && supportsCodexNativeCapability(bridge.codexPluginSkillReview.version, "installed-plugin-review") && typeof bridge.codexPluginSkillReview.catalogSha256 === "string" && /^[a-f0-9]{64}$/.test(bridge.codexPluginSkillReview.catalogSha256));
|
|
2569
|
+
for (const control of controls) {
|
|
2570
|
+
requireBound(object(control) && Object.keys(control).every((key) => ["pluginId", "namespace", "pluginParent", "sourceType", "sourceSha256"].includes(key)));
|
|
2571
|
+
text(control.pluginId, 1024);
|
|
2572
|
+
text(control.namespace, 64);
|
|
2573
|
+
text(control.pluginParent);
|
|
2574
|
+
requireBound(!ids.has(control.pluginId) && isAbsolute3(control.pluginParent) && resolve5(control.pluginParent) === control.pluginParent && ["local", "git", "npm"].includes(control.sourceType) && typeof control.sourceSha256 === "string" && /^[a-f0-9]{64}$/.test(control.sourceSha256));
|
|
2575
|
+
ids.add(control.pluginId);
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
if (bridge.discoveryExecutables !== undefined)
|
|
2579
|
+
for (const [agent, executable] of Object.entries(record(bridge.discoveryExecutables, AGENT_POLICY_LIMITS.agents))) {
|
|
2580
|
+
requireBound(agent === "gemini" && object(executable) && Object.keys(executable).length === 3 && executable.command === "gemini");
|
|
2581
|
+
for (const path of [executable.path, executable.target]) {
|
|
2582
|
+
text(path);
|
|
2583
|
+
requireBound(!/[\x00-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/.test(path) && isAbsolute3(path) && resolve5(path) === path);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
if (bridge.disabledBuiltins !== undefined)
|
|
2587
|
+
for (const builtin of array(bridge.disabledBuiltins, AGENT_POLICY_LIMITS.builtinNames)) {
|
|
2588
|
+
requireBound(object(builtin));
|
|
2589
|
+
text(builtin.path);
|
|
2590
|
+
text(builtin.hash, 64);
|
|
2591
|
+
}
|
|
2592
|
+
if (bridge.codexNativePolicy !== undefined) {
|
|
2593
|
+
const trust = record(bridge.codexNativePolicy, 1);
|
|
2594
|
+
for (const [platform, digests] of Object.entries(record(trust.executableDigests, 8))) {
|
|
2595
|
+
text(platform, 32);
|
|
2596
|
+
for (const digest of array(digests, 16))
|
|
2597
|
+
text(digest, 64);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
if (bridge.discovery === undefined)
|
|
2601
|
+
return;
|
|
2602
|
+
for (const [agent, value] of Object.entries(record(bridge.discovery, AGENT_POLICY_LIMITS.agents))) {
|
|
2603
|
+
text(agent, 128);
|
|
2604
|
+
requireBound(object(value));
|
|
2605
|
+
text(value.agent, 128);
|
|
2606
|
+
if (value.codexRetiredMaterializations !== undefined) {
|
|
2607
|
+
const proof = record(value.codexRetiredMaterializations, 3), roots = array(proof.roots, AGENT_POLICY_LIMITS.discoveryRoots), parents2 = proof.parents === undefined ? [] : array(proof.parents, AGENT_POLICY_LIMITS.discoveryRoots);
|
|
2608
|
+
requireBound(agent === "codex" && value.agent === "codex" && value.method === "reviewed" && Object.keys(proof).every((key) => ["roots", "parents", "directories"].includes(key)) && roots.length + parents2.length > 0 && new Set(roots).size === roots.length && new Set(parents2).size === parents2.length);
|
|
2609
|
+
for (const root of roots) {
|
|
2610
|
+
text(root);
|
|
2611
|
+
requireBound(isAbsolute3(root) && resolve5(root) === root);
|
|
2612
|
+
}
|
|
2613
|
+
for (const parent of parents2) {
|
|
2614
|
+
text(parent);
|
|
2615
|
+
requireBound(isAbsolute3(parent) && resolve5(parent) === parent);
|
|
2616
|
+
}
|
|
2617
|
+
for (const directory of array(proof.directories, AGENT_POLICY_LIMITS.discoveryDirectories)) {
|
|
2618
|
+
requireBound(object(directory));
|
|
2619
|
+
text(directory.path);
|
|
2620
|
+
requireBound(directory.sha256 === null || typeof directory.sha256 === "string" && /^[a-f0-9]{64}$/.test(directory.sha256));
|
|
2621
|
+
if (directory.entries !== undefined)
|
|
2622
|
+
for (const entry of array(directory.entries, AGENT_POLICY_LIMITS.discoveryDirectoryEntries))
|
|
2623
|
+
text(entry, AGENT_POLICY_LIMITS.pathCharacters * 6 + 100);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if (value.codexInstallationInputs !== undefined) {
|
|
2627
|
+
const proof = record(value.codexInstallationInputs, 4), inputs = array(proof.plugins, 4096), ids = new Set;
|
|
2628
|
+
requireBound(agent === "codex" && value.agent === "codex" && value.method === "reviewed" && supportsCodexNativeCapability(proof.version, "installed-plugin-review") && typeof proof.catalogSha256 === "string" && /^[a-f0-9]{64}$/.test(proof.catalogSha256) && inputs.length > 0 && object(bridge.codexPluginSkillReview) && bridge.codexPluginSkillReview.version === proof.version && bridge.codexPluginSkillReview.catalogSha256 === proof.catalogSha256);
|
|
2629
|
+
for (const input of inputs) {
|
|
2630
|
+
requireBound(object(input) && Object.keys(input).every((key) => ["pluginId", "namespace", "pluginParent", "sourceRoot", "sourceSha256"].includes(key)));
|
|
2631
|
+
text(input.pluginId, 1024);
|
|
2632
|
+
text(input.namespace, 64);
|
|
2633
|
+
text(input.pluginParent);
|
|
2634
|
+
text(input.sourceRoot);
|
|
2635
|
+
requireBound(!ids.has(input.pluginId) && [input.pluginParent, input.sourceRoot].every((path) => isAbsolute3(path) && resolve5(path) === path) && typeof input.sourceSha256 === "string" && /^[a-f0-9]{64}$/.test(input.sourceSha256));
|
|
2636
|
+
ids.add(input.pluginId);
|
|
2637
|
+
}
|
|
2638
|
+
if (proof.directories !== undefined)
|
|
2639
|
+
for (const directory of array(proof.directories, AGENT_POLICY_LIMITS.discoveryDirectories)) {
|
|
2640
|
+
requireBound(object(directory));
|
|
2641
|
+
text(directory.path);
|
|
2642
|
+
requireBound(directory.sha256 === null || typeof directory.sha256 === "string" && /^[a-f0-9]{64}$/.test(directory.sha256));
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
for (const root of array(value.roots, AGENT_POLICY_LIMITS.discoveryRoots))
|
|
2646
|
+
text(root);
|
|
2647
|
+
for (const source of array(value.sources, AGENT_POLICY_LIMITS.discoverySources)) {
|
|
2648
|
+
requireBound(object(source));
|
|
2649
|
+
text(source.path);
|
|
2650
|
+
requireBound(source.sha256 === null || typeof source.sha256 === "string" && /^[a-f0-9]{64}$/.test(source.sha256));
|
|
2651
|
+
if (source.hashMode !== undefined)
|
|
2652
|
+
requireBound(["bytes", "path-bytes", "claude-plugin-manifest-v1", "claude-plugin-registry", "claude-marketplace-registry", "claude-settings-v1", "claude-settings-v2", "claude-settings-v3", "claude-settings-v4", "claude-marketplace-registry-v2", "codex-settings-v1", "codex-settings-v2", "codex-settings-v3", "codex-settings-v4", "sumi-settings-v1", "claude-marketplace-entry-v1"].includes(source.hashMode) && source.format === undefined && source.fields === undefined && (source.hashMode === "bytes" || source.sha256 !== null));
|
|
2653
|
+
if (source.hashMode === "claude-plugin-manifest-v1")
|
|
2654
|
+
requireBound(agent === "claude" && value.agent === "claude" && ["automatic", "reviewed"].includes(value.method) && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute3(source.path) && resolve5(source.path) === source.path && source.path.endsWith(join5(sep, ".claude-plugin", "plugin.json")) && source.managedPlugins === undefined);
|
|
2655
|
+
if (source.hashMode === "sumi-settings-v1")
|
|
2656
|
+
requireBound(agent === "sumi" && value.agent === "sumi" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute3(source.path) && resolve5(source.path) === source.path && basename3(source.path) === "sumi.json");
|
|
2657
|
+
if (source.hashMode === "codex-settings-v1" || source.hashMode === "codex-settings-v2" || (source.hashMode === "codex-settings-v3" || source.hashMode === "codex-settings-v4"))
|
|
2658
|
+
requireBound(agent === "codex" && value.agent === "codex" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute3(source.path) && resolve5(source.path) === source.path && basename3(source.path) === "config.toml");
|
|
2659
|
+
if (source.hashMode === "claude-settings-v1" || source.hashMode === "claude-settings-v2" || (source.hashMode === "claude-settings-v3" || source.hashMode === "claude-settings-v4"))
|
|
2660
|
+
requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute3(source.path) && resolve5(source.path) === source.path && basename3(source.path) === "settings.json");
|
|
2661
|
+
if (source.hashMode === "claude-marketplace-registry" || source.hashMode === "claude-marketplace-registry-v2")
|
|
2662
|
+
requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && !/[\x00-\x1f\x7f]/.test(source.path) && isAbsolute3(source.path) && resolve5(source.path) === source.path && basename3(source.path) === "known_marketplaces.json");
|
|
2663
|
+
if (source.hashMode === "claude-marketplace-entry-v1")
|
|
2664
|
+
requireBound(agent === "claude" && value.agent === "claude" && value.method === "reviewed" && claudeMarketplaceEntrySourceValid(source));
|
|
2665
|
+
if (source.hashMode === "claude-plugin-registry") {
|
|
2666
|
+
requireBound(agent === "claude");
|
|
2667
|
+
const managed = array(source.managedPlugins, 64);
|
|
2668
|
+
requireBound(managed.length > 0);
|
|
2669
|
+
for (const entry of managed) {
|
|
2670
|
+
requireBound(object(entry) && Object.keys(entry).every((key) => ["bindingId", "storeRoot"].includes(key)));
|
|
2671
|
+
text(entry.storeRoot);
|
|
2672
|
+
requireBound(typeof entry.bindingId === "string" && /^[a-f0-9]{64}$/.test(entry.bindingId));
|
|
2673
|
+
}
|
|
2674
|
+
} else
|
|
2675
|
+
requireBound(source.managedPlugins === undefined);
|
|
2676
|
+
if (source.format !== undefined)
|
|
2677
|
+
requireBound(["json", "toml", "yaml"].includes(source.format));
|
|
2678
|
+
if (source.fields !== undefined)
|
|
2679
|
+
for (const field2 of array(source.fields, AGENT_POLICY_LIMITS.fields))
|
|
2680
|
+
text(field2, 256);
|
|
2681
|
+
}
|
|
2682
|
+
if (value.directories !== undefined)
|
|
2683
|
+
for (const directory of array(value.directories, AGENT_POLICY_LIMITS.discoveryDirectories)) {
|
|
2684
|
+
requireBound(object(directory));
|
|
2685
|
+
text(directory.path);
|
|
2686
|
+
requireBound(directory.sha256 === null || typeof directory.sha256 === "string" && /^[a-f0-9]{64}$/.test(directory.sha256));
|
|
2687
|
+
}
|
|
2688
|
+
if (value.builtinNames !== undefined)
|
|
2689
|
+
for (const name of array(value.builtinNames, AGENT_POLICY_LIMITS.builtinNames))
|
|
2690
|
+
text(name, 128);
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// src/lib/managed-policy.ts
|
|
2695
|
+
function refusal(reason2 = "is unreadable") {
|
|
2696
|
+
return new SkillSelectionError("INVALID_AGENT_POLICY", `The Skills agent loading policy ${reason2}; refusing legacy fallback.`);
|
|
2697
|
+
}
|
|
2698
|
+
function parseManagedSkillPolicy(text2) {
|
|
2699
|
+
if (Buffer.byteLength(text2) > AGENT_POLICY_LIMITS.bytes)
|
|
2700
|
+
throw refusal("exceeds its size limit");
|
|
2701
|
+
let value;
|
|
2702
|
+
try {
|
|
2703
|
+
value = JSON.parse(text2);
|
|
2704
|
+
} catch {
|
|
2705
|
+
throw refusal();
|
|
2706
|
+
}
|
|
2707
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.loading !== "cli" || value.version !== undefined && value.version !== 1)
|
|
2708
|
+
throw refusal();
|
|
2709
|
+
if (value.profileId !== undefined && (typeof value.profileId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value.profileId) || value.profileId.includes("..")))
|
|
2710
|
+
throw refusal();
|
|
2711
|
+
try {
|
|
2712
|
+
assertAgentPolicyCollections(value);
|
|
2713
|
+
} catch {
|
|
2714
|
+
throw refusal("has invalid collection bounds");
|
|
2715
|
+
}
|
|
2716
|
+
return value;
|
|
2717
|
+
}
|
|
2718
|
+
function serializeManagedSkillPolicy(value) {
|
|
2719
|
+
const text2 = `${JSON.stringify(value, null, 2)}
|
|
2720
|
+
`;
|
|
2721
|
+
parseManagedSkillPolicy(text2);
|
|
2722
|
+
return text2;
|
|
2723
|
+
}
|
|
2724
|
+
function readManagedSkillPolicySnapshot(dataDir2 = getDataDirReadOnly()) {
|
|
2725
|
+
const path = join6(dataDir2, "agent-policy.json");
|
|
2726
|
+
let descriptor, observed = false;
|
|
2727
|
+
try {
|
|
2728
|
+
const stat = lstatSync3(path);
|
|
2729
|
+
observed = true;
|
|
2730
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
2731
|
+
throw refusal();
|
|
2732
|
+
if (stat.size > AGENT_POLICY_LIMITS.bytes)
|
|
2733
|
+
throw refusal("exceeds its size limit");
|
|
2734
|
+
descriptor = openSync3(path, constants3.O_RDONLY | (constants3.O_NOFOLLOW ?? 0) | constants3.O_NONBLOCK);
|
|
2735
|
+
const opened = fstatSync3(descriptor);
|
|
2736
|
+
if (!opened.isFile())
|
|
2737
|
+
throw refusal();
|
|
2738
|
+
if (opened.size > AGENT_POLICY_LIMITS.bytes)
|
|
2739
|
+
throw refusal("exceeds its size limit");
|
|
2740
|
+
const bytes = Buffer.allocUnsafe(AGENT_POLICY_LIMITS.bytes + 1);
|
|
2741
|
+
let length = 0;
|
|
2742
|
+
while (length < bytes.length) {
|
|
2743
|
+
const count = readSync3(descriptor, bytes, length, bytes.length - length, null);
|
|
2744
|
+
if (count === 0)
|
|
2745
|
+
break;
|
|
2746
|
+
length += count;
|
|
2747
|
+
}
|
|
2748
|
+
if (length > AGENT_POLICY_LIMITS.bytes)
|
|
2749
|
+
throw refusal("exceeds its size limit");
|
|
2750
|
+
const text2 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length));
|
|
2751
|
+
return { text: text2, value: parseManagedSkillPolicy(text2) };
|
|
2752
|
+
} catch (error) {
|
|
2753
|
+
if (error.code === "ENOENT" && !observed) {
|
|
2754
|
+
if (lstatSync3(path, { throwIfNoEntry: false }) === undefined)
|
|
2755
|
+
return null;
|
|
2756
|
+
}
|
|
2757
|
+
if (error instanceof SkillSelectionError)
|
|
2758
|
+
throw error;
|
|
2759
|
+
throw refusal();
|
|
2760
|
+
} finally {
|
|
2761
|
+
if (descriptor !== undefined)
|
|
2762
|
+
closeSync3(descriptor);
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
function readManagedSkillPolicy(dataDir2 = getDataDirReadOnly()) {
|
|
2766
|
+
return readManagedSkillPolicySnapshot(dataDir2)?.value ?? null;
|
|
2767
|
+
}
|
|
2768
|
+
function requiresCliSkillLoading(dataDir2 = getDataDirReadOnly()) {
|
|
2769
|
+
return readManagedSkillPolicy(dataDir2)?.loading === "cli";
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// src/lib/agent-install-selection.ts
|
|
2773
|
+
function detectedIntegrationAgents(options = {}) {
|
|
2774
|
+
const home = options.home ?? homedir2(), env = options.env ?? process.env;
|
|
2775
|
+
const policy = readManagedSkillPolicySnapshot(options.dataDir ?? getDataDirReadOnly())?.value;
|
|
2776
|
+
const bridge = policy?.bridge;
|
|
2777
|
+
if (bridge !== undefined && (!bridge || typeof bridge !== "object" || Array.isArray(bridge)))
|
|
2778
|
+
throw new Error("Invalid existing Skills bridge policy");
|
|
2779
|
+
const configured = bridge === undefined ? [] : bridge.agents;
|
|
2780
|
+
if (!Array.isArray(configured) || configured.some((agent) => !INTEGRATION_AGENTS.includes(agent)))
|
|
2781
|
+
throw new Error("Invalid existing bridge agent inventory");
|
|
2782
|
+
const present = (path) => {
|
|
2783
|
+
try {
|
|
2784
|
+
return lstatSync4(path, { throwIfNoEntry: false }) !== undefined;
|
|
2785
|
+
} catch {
|
|
2786
|
+
throw new Error("Agent presence cannot be inspected; review native configuration before hook installation");
|
|
2787
|
+
}
|
|
2788
|
+
};
|
|
2789
|
+
const selected = INTEGRATION_AGENTS.filter((agent) => {
|
|
2790
|
+
if (configured.includes(agent) || Bun.which(agent, { PATH: env.PATH ?? "" }))
|
|
2791
|
+
return true;
|
|
2792
|
+
if (agent === "sumi") {
|
|
2793
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
2794
|
+
const xdgSumi = xdg?.trim() ? resolve6(options.cwd ?? process.cwd(), xdg === "~" ? home : xdg.startsWith("~/") || xdg.startsWith("~\\") ? resolve6(home, xdg.slice(2)) : xdg, "sumi") : null;
|
|
2795
|
+
return ["SUMI_HOME", "SUMI_CONFIG_DIR", "SUMI_CONFIG", "SUMI_CONFIG_CONTENT"].some((key) => env[key] !== undefined) || Bun.which("sumi-paths", { PATH: env.PATH ?? "" }) !== null || NATIVE_SKILL_ROOTS.some(([provider, root]) => provider === "sumi" && present(join7(home, dirname5(root)))) || present(join7(home, ".config", "sumi")) || xdgSumi !== null && present(xdgSumi);
|
|
2796
|
+
}
|
|
2797
|
+
if (agent === "hermes" && env.HERMES_HOME !== undefined)
|
|
2798
|
+
return true;
|
|
2799
|
+
const adapter = AGENT_ADAPTERS[agent];
|
|
2800
|
+
return present(join7(home, dirname5(adapter.config))) || NATIVE_SKILL_ROOTS.some(([provider, root]) => provider === agent && dirname5(root) === `.${agent}` && present(join7(home, dirname5(root))));
|
|
2801
|
+
});
|
|
2802
|
+
if (!selected.length && !options.allowEmpty)
|
|
2803
|
+
throw new Error(`No configured or detected agents; select --agent <agent> explicitly (${INTEGRATION_AGENTS.join(", ")}).`);
|
|
2804
|
+
return selected;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// src/lib/agent-sumi.ts
|
|
2808
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2809
|
+
import { accessSync, constants as constants4, existsSync as existsSync4, lstatSync as lstatSync5, realpathSync, statSync as statSync2 } from "fs";
|
|
2810
|
+
import { delimiter, isAbsolute as isAbsolute4, join as join8, resolve as resolve7 } from "path";
|
|
2811
|
+
var SUMI_PATH_SELECTORS = ["SUMI_HOME", "SUMI_CONFIG_DIR", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME"];
|
|
2812
|
+
var SUMI_PATH_KEYS = ["data", "cache", "config", "state"];
|
|
2813
|
+
var SUMI_PATH_OUTPUT_LIMIT = 128 * 1024;
|
|
2814
|
+
|
|
2815
|
+
class SumiPathResolverError extends Error {
|
|
2816
|
+
code;
|
|
2817
|
+
constructor(code) {
|
|
2818
|
+
super(`Sumi path discovery refused (${code})`);
|
|
2819
|
+
this.code = code;
|
|
2820
|
+
this.name = "SumiPathResolverError";
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
function recordWithKeys(value, keys) {
|
|
2824
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
2825
|
+
}
|
|
2826
|
+
function absolutePath2(value) {
|
|
2827
|
+
return typeof value === "string" && value.length > 0 && value.length <= 32768 && !/[\x00-\x1f\x7f]/.test(value) && isAbsolute4(value) && resolve7(value) === value;
|
|
2828
|
+
}
|
|
2829
|
+
function absoluteInput(value) {
|
|
2830
|
+
return value.length > 0 && value.length <= 32768 && !/[\x00-\x1f\x7f]/.test(value) && isAbsolute4(value);
|
|
2831
|
+
}
|
|
2832
|
+
function resolveSumiPathsCommand(pathValue) {
|
|
2833
|
+
if (!pathValue)
|
|
2834
|
+
return null;
|
|
2835
|
+
for (const directory of pathValue.split(delimiter)) {
|
|
2836
|
+
if (!directory || /[\x00-\x1f\x7f]/.test(directory))
|
|
2837
|
+
continue;
|
|
2838
|
+
const candidate = resolve7(directory, "sumi-paths");
|
|
2839
|
+
try {
|
|
2840
|
+
accessSync(candidate, constants4.X_OK);
|
|
2841
|
+
if (statSync2(candidate).isFile())
|
|
2842
|
+
return candidate;
|
|
2843
|
+
} catch {}
|
|
2844
|
+
}
|
|
2845
|
+
return null;
|
|
2846
|
+
}
|
|
2847
|
+
function validPathPlan(value, home, cwd) {
|
|
2848
|
+
if (!recordWithKeys(value, ["schemaVersion", "kind", "home", "cwd", "roots", "legacyRoots", "configFiles", "skillRoots"]))
|
|
2849
|
+
return false;
|
|
2850
|
+
if (value.schemaVersion !== 1 || value.kind !== "sumi-paths" || value.home !== resolve7(home) || value.cwd !== resolve7(cwd))
|
|
2851
|
+
return false;
|
|
2852
|
+
if (!recordWithKeys(value.roots, SUMI_PATH_KEYS) || !recordWithKeys(value.legacyRoots, SUMI_PATH_KEYS))
|
|
2853
|
+
return false;
|
|
2854
|
+
for (const key of SUMI_PATH_KEYS) {
|
|
2855
|
+
if (!absolutePath2(value.roots[key]))
|
|
2856
|
+
return false;
|
|
2857
|
+
if (value.legacyRoots[key] !== null && !absolutePath2(value.legacyRoots[key]))
|
|
2858
|
+
return false;
|
|
2859
|
+
}
|
|
2860
|
+
if (!recordWithKeys(value.configFiles, ["canonical", "legacy"]) || !recordWithKeys(value.skillRoots, ["canonical", "legacy"]))
|
|
2861
|
+
return false;
|
|
2862
|
+
if (!absolutePath2(value.configFiles.canonical) || !absolutePath2(value.skillRoots.canonical))
|
|
2863
|
+
return false;
|
|
2864
|
+
if (value.configFiles.legacy !== null && !absolutePath2(value.configFiles.legacy))
|
|
2865
|
+
return false;
|
|
2866
|
+
if (value.skillRoots.legacy !== null && !absolutePath2(value.skillRoots.legacy))
|
|
2867
|
+
return false;
|
|
2868
|
+
const configRoot = value.roots.config;
|
|
2869
|
+
const legacyConfigRoot = value.legacyRoots.config;
|
|
2870
|
+
if (typeof configRoot !== "string" || legacyConfigRoot !== null && typeof legacyConfigRoot !== "string")
|
|
2871
|
+
return false;
|
|
2872
|
+
if (value.configFiles.canonical !== join8(configRoot, "sumi.json") || value.skillRoots.canonical !== join8(configRoot, "skills"))
|
|
2873
|
+
return false;
|
|
2874
|
+
if (legacyConfigRoot === null)
|
|
2875
|
+
return value.configFiles.legacy === null && value.skillRoots.legacy === null;
|
|
2876
|
+
return value.configFiles.legacy === join8(legacyConfigRoot, "sumi.json") && value.skillRoots.legacy === join8(legacyConfigRoot, "skills");
|
|
2877
|
+
}
|
|
2878
|
+
function sumiPathPlan(home, cwd = process.cwd(), env = process.env) {
|
|
2879
|
+
for (const key of ["SUMI_CONFIG", "SUMI_CONFIG_CONTENT"])
|
|
2880
|
+
if (env[key] !== undefined)
|
|
2881
|
+
throw new SumiPathResolverError("SUMI_PATH_CONFIG_UNSUPPORTED");
|
|
2882
|
+
if (!absoluteInput(home) || !absoluteInput(cwd))
|
|
2883
|
+
throw new SumiPathResolverError("SUMI_PATH_INPUT_REFUSED");
|
|
2884
|
+
const runtimePath = process.env.PATH ?? "";
|
|
2885
|
+
const executable = resolveSumiPathsCommand(runtimePath);
|
|
2886
|
+
if (!executable || !isAbsolute4(executable))
|
|
2887
|
+
throw new SumiPathResolverError("SUMI_PATH_RESOLVER_UNAVAILABLE");
|
|
2888
|
+
const childEnv = { PATH: runtimePath };
|
|
2889
|
+
for (const key of SUMI_PATH_SELECTORS)
|
|
2890
|
+
if (env[key] !== undefined)
|
|
2891
|
+
childEnv[key] = env[key];
|
|
2892
|
+
let child2;
|
|
2893
|
+
try {
|
|
2894
|
+
child2 = spawnSync2(executable, ["--json", "--home", resolve7(home), "--cwd", resolve7(cwd)], {
|
|
2895
|
+
cwd: resolve7(cwd),
|
|
2896
|
+
env: childEnv,
|
|
2897
|
+
encoding: "buffer",
|
|
2898
|
+
timeout: 3000,
|
|
2899
|
+
maxBuffer: SUMI_PATH_OUTPUT_LIMIT,
|
|
2900
|
+
shell: false
|
|
2901
|
+
});
|
|
2902
|
+
} catch {
|
|
2903
|
+
throw new SumiPathResolverError("SUMI_PATH_RESOLVER_UNAVAILABLE");
|
|
2904
|
+
}
|
|
2905
|
+
const stdoutBytes = child2.stdout === null ? 0 : Buffer.byteLength(child2.stdout);
|
|
2906
|
+
const stderrBytes = child2.stderr === null ? 0 : Buffer.byteLength(child2.stderr);
|
|
2907
|
+
if (child2.error || child2.signal || !child2.stdout || stdoutBytes > SUMI_PATH_OUTPUT_LIMIT || stderrBytes > 0) {
|
|
2908
|
+
throw new SumiPathResolverError("SUMI_PATH_RESOLVER_UNAVAILABLE");
|
|
2909
|
+
}
|
|
2910
|
+
let result;
|
|
2911
|
+
try {
|
|
2912
|
+
result = JSON.parse(child2.stdout.toString("utf8"));
|
|
2913
|
+
} catch {
|
|
2914
|
+
throw new SumiPathResolverError("SUMI_PATH_RESOLVER_INVALID_RESPONSE");
|
|
2915
|
+
}
|
|
2916
|
+
if (recordWithKeys(result, ["schemaVersion", "kind", "code"]) && result.schemaVersion === 1 && result.kind === "sumi-paths-error" && (result.code === "SUMI_PATH_INPUT_REFUSED" || result.code === "SUMI_PATH_CONFIG_UNSUPPORTED") && child2.status === 2) {
|
|
2917
|
+
throw new SumiPathResolverError(result.code);
|
|
2918
|
+
}
|
|
2919
|
+
if (child2.status !== 0 || !validPathPlan(result, home, cwd))
|
|
2920
|
+
throw new SumiPathResolverError("SUMI_PATH_RESOLVER_INVALID_RESPONSE");
|
|
2921
|
+
return result;
|
|
2922
|
+
}
|
|
2923
|
+
function selectedConfigDirectory(plan) {
|
|
2924
|
+
const canonical3 = plan.roots.config, legacy = plan.legacyRoots.config;
|
|
2925
|
+
if (legacy === null)
|
|
2926
|
+
return existsSync4(canonical3) ? realpathSync(canonical3) : canonical3;
|
|
2927
|
+
const canonicalStat = lstatSync5(canonical3, { throwIfNoEntry: false }), legacyStat = lstatSync5(legacy, { throwIfNoEntry: false });
|
|
2928
|
+
if (canonicalStat && legacyStat && realpathSync(canonical3) !== realpathSync(legacy))
|
|
2929
|
+
throw new Error("Sumi config roots conflict; complete native home adoption before Skills integration");
|
|
2930
|
+
const selected = canonicalStat ? canonical3 : legacyStat ? legacy : canonical3;
|
|
2931
|
+
if (!existsSync4(selected))
|
|
2932
|
+
return selected;
|
|
2933
|
+
const physical = realpathSync(selected);
|
|
2934
|
+
if ((canonicalStat?.isSymbolicLink() || legacyStat?.isSymbolicLink()) && ![canonical3, legacy].includes(physical))
|
|
2935
|
+
throw new Error("Sumi config alias must resolve to its canonical or legacy root");
|
|
2936
|
+
return physical;
|
|
2937
|
+
}
|
|
2938
|
+
function sumiConfigDirectory(home, env = process.env, cwd = process.cwd()) {
|
|
2939
|
+
return selectedConfigDirectory(sumiPathPlan(home, cwd, env));
|
|
2940
|
+
}
|
|
2941
|
+
function sumiSkillRoots(home, cwd = process.cwd(), env = process.env) {
|
|
2942
|
+
const plan = sumiPathPlan(home, cwd, env);
|
|
2943
|
+
return [join8(selectedConfigDirectory(plan), "skills")];
|
|
2944
|
+
}
|
|
2945
|
+
function sumiBridgeRoot(home, cwd = process.cwd(), env = process.env) {
|
|
2946
|
+
return sumiSkillRoots(home, cwd, env)[0];
|
|
2947
|
+
}
|
|
2948
|
+
function sumiConfigPath(home, cwd = process.cwd(), env = process.env) {
|
|
2949
|
+
return join8(sumiConfigDirectory(home, env, cwd), "sumi.json");
|
|
2950
|
+
}
|
|
2951
|
+
function renderSumiPlugin(command, profile) {
|
|
2952
|
+
return renderSumiPluginVersion(command, profile, false);
|
|
2953
|
+
}
|
|
2954
|
+
function isManagedSumiPlugin(text2, command, profile) {
|
|
2955
|
+
return text2 === renderSumiPlugin(command, profile) || text2 === renderSumiPluginVersion(command, profile, true);
|
|
2956
|
+
}
|
|
2957
|
+
function renderSumiPluginVersion(command, profile, legacy) {
|
|
2958
|
+
const failure = (message) => legacy ? `new Error(${JSON.stringify(message)})` : "refusal()";
|
|
2959
|
+
return `// Managed by @hasna/skills. Regenerate with skills hook install --agent sumi.
|
|
2960
|
+
import { spawn } from "node:child_process";
|
|
2961
|
+
const command = ${JSON.stringify(command)};
|
|
2962
|
+
const profile = ${JSON.stringify(profile)};
|
|
2963
|
+
${legacy ? "" : `function refusal() {
|
|
2964
|
+
const error = new Error("Skills verification blocked this request. Review the Sumi Skills hook configuration, then retry.");
|
|
2965
|
+
error.name = "SkillsHookRefusal";
|
|
2966
|
+
error.skillsHookRefusal = { version: 1, code: "SKILLS_HOOK_REFUSED" };
|
|
2967
|
+
return error;
|
|
2968
|
+
}
|
|
2969
|
+
`}function context(input) {
|
|
2970
|
+
return new Promise((resolve, reject) => {
|
|
2971
|
+
const child = spawn(command, ["hook", "user-prompt", "--agent", "sumi", "--selection-profile", profile], { cwd: input.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
2972
|
+
const chunks = []; let bytes = 0, failure;
|
|
2973
|
+
const timer = setTimeout(() => { failure = ${failure("Skills prompt hook timed out")}; child.kill("SIGKILL"); }, 15000);
|
|
2974
|
+
child.stdout.on("data", chunk => { bytes += chunk.length; if (bytes > 1048576) { failure = ${failure("Skills prompt hook output exceeded its limit")}; child.kill("SIGKILL"); } else chunks.push(chunk); });
|
|
2975
|
+
child.stderr.on("data", () => {});
|
|
2976
|
+
child.stdin.on("error", () => {});
|
|
2977
|
+
child.on("error", error => { clearTimeout(timer); reject(${legacy ? "error" : "refusal()"}); });
|
|
2978
|
+
child.on("close", code => {
|
|
2979
|
+
clearTimeout(timer);
|
|
2980
|
+
if (failure || code !== 0) return reject(failure ?? ${failure("Skills prompt hook failed")});
|
|
2981
|
+
try {
|
|
2982
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2983
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) throw ${failure("Invalid Skills hook response")};
|
|
2984
|
+
if (result.decision === "block" || result.continue === false) throw ${failure("Skills prompt hook refused")};
|
|
2985
|
+
if (Object.keys(result).length && (!result.hookSpecificOutput || result.hookSpecificOutput.hookEventName !== input.hook_event_name)) throw ${failure("Invalid Skills hook response")};
|
|
2986
|
+
const text = result.hookSpecificOutput?.additionalContext ?? "";
|
|
2987
|
+
if (typeof text !== "string") throw ${failure("Invalid Skills prompt context")};
|
|
2988
|
+
resolve(text);
|
|
2989
|
+
} catch (error) { reject(${legacy ? "error" : "refusal()"}); }
|
|
2990
|
+
});
|
|
2991
|
+
child.stdin.end(JSON.stringify(input));
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
export default {
|
|
2995
|
+
id: "local.hasna-skills-cli",
|
|
2996
|
+
async setup(ctx) {
|
|
2997
|
+
if (!ctx?.session?.hook || !ctx?.session?.get || !ctx?.skill?.transform || !ctx?.tool?.hook) throw ${failure("Unsupported Sumi native Skills contract")};
|
|
2998
|
+
const registrations = [];
|
|
2999
|
+
const sessions = new Set();
|
|
3000
|
+
async function input(sessionID, prompt, first) {
|
|
3001
|
+
// The Promise adapter unwraps single-data endpoint responses.
|
|
3002
|
+
${legacy ? "const session = await ctx.session.get({ sessionID });" : `let session;
|
|
3003
|
+
try { session = await ctx.session.get({ sessionID }); } catch { throw refusal(); }`}
|
|
3004
|
+
if (!session || session.id !== sessionID || typeof session.location?.directory !== "string" || (session.parentID !== undefined && typeof session.parentID !== "string") || session.parentID === sessionID) throw ${failure("Invalid Sumi session custody")};
|
|
3005
|
+
return { hook_event_name: first ? session.parentID ? "SubagentStart" : "SessionStart" : "UserPromptSubmit", cwd: session.location.directory, session_id: sessionID, parent_session_id: session.parentID ?? null, prompt, restore: true };
|
|
3006
|
+
}
|
|
3007
|
+
registrations.push(await ctx.skill.transform(editor => {
|
|
3008
|
+
for (const skill of editor.list()) if (skill.id !== "skills-cli" || skill.name !== "skills-cli") editor.remove(skill.id);
|
|
3009
|
+
}));
|
|
3010
|
+
// Native config transforms may add skills after this transform. Guard
|
|
3011
|
+
// both direct tool calls and prompt attachments at the consumption edge.
|
|
3012
|
+
registrations.push(await ctx.tool.hook("execute.before", event => {
|
|
3013
|
+
if (event.tool === "skill" && event.input?.id !== "skills-cli") throw ${failure("Native Sumi skill payload refused; use Skills CLI")};
|
|
3014
|
+
}));
|
|
3015
|
+
registrations.push(await ctx.session.hook("prompt", async event => {
|
|
3016
|
+
if (typeof event.prompt?.text !== "string") throw ${failure("Invalid Sumi prompt")};
|
|
3017
|
+
if (event.prompt.skills !== undefined && (!Array.isArray(event.prompt.skills) || event.prompt.skills.some(skill => skill?.id !== "skills-cli"))) throw ${failure("Native Sumi skill attachment refused; use Skills CLI")};
|
|
3018
|
+
await context(await input(event.sessionID, event.prompt.text, !sessions.has(event.sessionID)));
|
|
3019
|
+
sessions.add(event.sessionID);
|
|
3020
|
+
}));
|
|
3021
|
+
registrations.push(await ctx.session.hook("context", async event => {
|
|
3022
|
+
const last = event.messages.findLast(message => message.role === "user");
|
|
3023
|
+
const prompt = (last?.content ?? []).filter(part => part.type === "text").map(part => part.text).join("\\n");
|
|
3024
|
+
// Recheck on every primary request, including resumed and synthetic
|
|
3025
|
+
// sessions. The CLI owns optional failures versus explicit refusals.
|
|
3026
|
+
const text = await context(await input(event.sessionID, prompt, !sessions.has(event.sessionID)));
|
|
3027
|
+
if (text) event.system.push({ type: "text", text });
|
|
3028
|
+
sessions.add(event.sessionID);
|
|
3029
|
+
}));
|
|
3030
|
+
return async () => { for (const registration of registrations) await registration.dispose(); };
|
|
3031
|
+
},
|
|
3032
|
+
};
|
|
3033
|
+
`;
|
|
3034
|
+
}
|
|
3035
|
+
var SUMI_SKILL_PERMISSIONS = [
|
|
3036
|
+
{ action: "skill", resource: "*", effect: "deny" },
|
|
3037
|
+
{ action: "skill", resource: "skills-cli", effect: "allow" }
|
|
3038
|
+
];
|
|
3039
|
+
|
|
3040
|
+
// src/runtime-prerequisites.ts
|
|
3041
|
+
var RUNTIME_PREREQUISITES_SCHEMA = "skills.runtime-prerequisites.v1";
|
|
3042
|
+
var RUNTIME_PREREQUISITE_CODES = [
|
|
3043
|
+
"RUNTIME_CONSUMER_DISCOVERY_REFUSED",
|
|
3044
|
+
"RUNTIME_PREREQUISITE_INPUT_REFUSED",
|
|
3045
|
+
"SUMI_PATH_INPUT_REFUSED",
|
|
3046
|
+
"SUMI_PATH_CONFIG_UNSUPPORTED",
|
|
3047
|
+
"SUMI_PATH_RESOLVER_UNAVAILABLE",
|
|
3048
|
+
"SUMI_PATH_RESOLVER_INVALID_RESPONSE",
|
|
3049
|
+
"SUMI_PATH_DISCOVERY_REFUSED"
|
|
3050
|
+
];
|
|
3051
|
+
function runtimePrerequisites(home, cwd) {
|
|
3052
|
+
const result = {
|
|
3053
|
+
schema: RUNTIME_PREREQUISITES_SCHEMA,
|
|
3054
|
+
targetVersion: version,
|
|
3055
|
+
ok: false,
|
|
3056
|
+
checked: [],
|
|
3057
|
+
code: null
|
|
3058
|
+
};
|
|
3059
|
+
if (!isAbsolute5(home) || !isAbsolute5(cwd))
|
|
3060
|
+
return { ...result, code: "RUNTIME_PREREQUISITE_INPUT_REFUSED" };
|
|
3061
|
+
let agents;
|
|
3062
|
+
try {
|
|
3063
|
+
agents = detectedIntegrationAgents({ home, cwd, allowEmpty: true });
|
|
3064
|
+
} catch {
|
|
3065
|
+
return { ...result, code: "RUNTIME_CONSUMER_DISCOVERY_REFUSED" };
|
|
3066
|
+
}
|
|
3067
|
+
if (agents.includes("sumi")) {
|
|
3068
|
+
try {
|
|
3069
|
+
sumiConfigDirectory(home, process.env, cwd);
|
|
3070
|
+
result.checked.push("sumi-paths");
|
|
3071
|
+
} catch (error) {
|
|
3072
|
+
const code = error instanceof SumiPathResolverError && RUNTIME_PREREQUISITE_CODES.includes(error.code) ? error.code : "SUMI_PATH_DISCOVERY_REFUSED";
|
|
3073
|
+
return { ...result, code };
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
return { ...result, ok: true };
|
|
3077
|
+
}
|
|
3078
|
+
if (import.meta.main) {
|
|
3079
|
+
const [home, cwd, ...extra] = process.argv.slice(2);
|
|
3080
|
+
const result = !home || !cwd || extra.length ? { schema: RUNTIME_PREREQUISITES_SCHEMA, targetVersion: version, ok: false, checked: [], code: "RUNTIME_PREREQUISITE_INPUT_REFUSED" } : runtimePrerequisites(home, cwd);
|
|
3081
|
+
process.stdout.write(`${JSON.stringify(result)}
|
|
3082
|
+
`);
|
|
3083
|
+
process.exitCode = result.ok ? 0 : 2;
|
|
3084
|
+
}
|
|
3085
|
+
export {
|
|
3086
|
+
runtimePrerequisites,
|
|
3087
|
+
RUNTIME_PREREQUISITE_CODES,
|
|
3088
|
+
RUNTIME_PREREQUISITES_SCHEMA
|
|
3089
|
+
};
|