knodin 0.8.3 → 0.8.5

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.
Files changed (42) hide show
  1. package/README.md +7 -1
  2. package/dist/bin/cli.js +489 -78
  3. package/dist/src/agent-events.js +128 -0
  4. package/dist/src/agent-hooks.js +156 -0
  5. package/dist/src/cli-model.js +42 -1
  6. package/dist/src/engine/candidate-database.js +167 -0
  7. package/dist/src/engine/embeddings.js +8 -5
  8. package/dist/src/engine/index.js +930 -262
  9. package/dist/src/engine/prune.js +8 -0
  10. package/dist/src/file-metrics.js +153 -0
  11. package/dist/src/output-telemetry.js +8 -3
  12. package/dist/src/repair-lease.js +8 -5
  13. package/dist/src/response-budget.js +2 -1
  14. package/dist/src/session-telemetry.js +163 -0
  15. package/dist/src/shared-index/artifact.js +65 -0
  16. package/dist/src/shared-index/cache.js +267 -0
  17. package/dist/src/shared-index/compatibility.js +70 -0
  18. package/dist/src/shared-index/config.js +224 -0
  19. package/dist/src/shared-index/contract.js +117 -0
  20. package/dist/src/shared-index/manifest.js +71 -0
  21. package/dist/src/shared-index/opportunistic.js +37 -0
  22. package/dist/src/shared-index/overlay.js +98 -0
  23. package/dist/src/shared-index/provenance.js +149 -0
  24. package/dist/src/shared-index/publisher.js +198 -0
  25. package/dist/src/shared-index/restore.js +209 -0
  26. package/dist/src/shared-index/s3-client.js +124 -0
  27. package/dist/src/shared-index/schemas.js +86 -0
  28. package/dist/src/shared-index/selection.js +111 -0
  29. package/dist/src/tools/knodin-tools.js +70 -3
  30. package/docs/CLI.md +56 -0
  31. package/docs/INSTALLATION.md +11 -0
  32. package/docs/MCP.md +12 -0
  33. package/docs/SHARED-INDEX-CONTRACT.md +132 -0
  34. package/docs/TELEMETRY.md +19 -1
  35. package/docs/releases/0.8.4.md +22 -0
  36. package/docs/releases/0.8.5.md +25 -0
  37. package/package.json +12 -3
  38. package/roadmap/competitive-roadmap.md +20 -0
  39. package/schemas/shared-index-branch-pointer-v1.schema.json +66 -0
  40. package/schemas/shared-index-config-v1.schema.json +97 -0
  41. package/schemas/shared-index-manifest-v1.schema.json +104 -0
  42. package/schemas/shared-index-provenance-v1.schema.json +100 -0
@@ -0,0 +1,267 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { SharedIndexError } from "./s3-client.js";
6
+ const DIGEST = /^[a-f0-9]{64}$/;
7
+ const CACHE_FILE = "graph.sqlite.zst";
8
+ function assertDigest(digest) {
9
+ if (!DIGEST.test(digest))
10
+ throw new SharedIndexError("integrity", "shared-index cache digest is invalid");
11
+ }
12
+ function hashFile(target) {
13
+ const hash = crypto.createHash("sha256");
14
+ const descriptor = fs.openSync(target, "r");
15
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
16
+ try {
17
+ let bytes = 0;
18
+ do {
19
+ bytes = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
20
+ if (bytes > 0)
21
+ hash.update(buffer.subarray(0, bytes));
22
+ } while (bytes > 0);
23
+ }
24
+ finally {
25
+ fs.closeSync(descriptor);
26
+ }
27
+ return hash.digest("hex");
28
+ }
29
+ function writeAll(descriptor, bytes) {
30
+ let offset = 0;
31
+ while (offset < bytes.byteLength)
32
+ offset += fs.writeSync(descriptor, bytes, offset, bytes.byteLength - offset);
33
+ }
34
+ function fsyncDirectory(target) {
35
+ try {
36
+ const descriptor = fs.openSync(target, "r");
37
+ try {
38
+ fs.fsyncSync(descriptor);
39
+ }
40
+ finally {
41
+ fs.closeSync(descriptor);
42
+ }
43
+ }
44
+ catch (error) {
45
+ if (process.platform !== "win32")
46
+ throw error;
47
+ }
48
+ }
49
+ function processExists(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch (error) {
55
+ return error.code === "EPERM";
56
+ }
57
+ }
58
+ function removeStaleLeases(directory) {
59
+ if (!fs.existsSync(directory))
60
+ return;
61
+ for (const name of fs.readdirSync(directory)) {
62
+ const match = /^\.lease-(\d+)-/.exec(name);
63
+ if (match && !processExists(Number(match[1])))
64
+ fs.rmSync(path.join(directory, name), { force: true });
65
+ }
66
+ }
67
+ function isLeased(directory) {
68
+ removeStaleLeases(directory);
69
+ return (fs.existsSync(directory) && fs.readdirSync(directory).some((name) => name.startsWith(".lease-")));
70
+ }
71
+ function wait(milliseconds, signal) {
72
+ return new Promise((resolve, reject) => {
73
+ const finish = () => {
74
+ signal?.removeEventListener("abort", abort);
75
+ resolve();
76
+ };
77
+ const timer = setTimeout(finish, milliseconds);
78
+ const abort = () => {
79
+ clearTimeout(timer);
80
+ signal?.removeEventListener("abort", abort);
81
+ reject(new SharedIndexError("timeout", "shared-index cache wait was cancelled"));
82
+ };
83
+ if (signal?.aborted)
84
+ abort();
85
+ else
86
+ signal?.addEventListener("abort", abort, { once: true });
87
+ });
88
+ }
89
+ export class SharedIndexCache {
90
+ root;
91
+ maxEntries;
92
+ maxBytes;
93
+ lockTimeoutMs;
94
+ constructor(policy = {}) {
95
+ this.root = path.resolve(policy.root ?? path.join(os.homedir(), ".knodin", "shared-index-cache", "v1", "sha256"));
96
+ this.maxEntries = policy.maxEntries ?? 32;
97
+ this.maxBytes = policy.maxBytes ?? 32 * 1024 ** 3;
98
+ this.lockTimeoutMs = policy.lockTimeoutMs ?? 30_000;
99
+ if (!Number.isSafeInteger(this.maxEntries) ||
100
+ this.maxEntries < 1 ||
101
+ !Number.isSafeInteger(this.maxBytes) ||
102
+ this.maxBytes < 1 ||
103
+ !Number.isSafeInteger(this.lockTimeoutMs) ||
104
+ this.lockTimeoutMs < 1)
105
+ throw new SharedIndexError("configuration", "shared-index cache policy is invalid");
106
+ fs.mkdirSync(this.root, { recursive: true, mode: 0o700 });
107
+ if (fs.lstatSync(this.root).isSymbolicLink() || !fs.lstatSync(this.root).isDirectory())
108
+ throw new SharedIndexError("configuration", "shared-index cache root is not a private directory");
109
+ fs.chmodSync(this.root, 0o700);
110
+ }
111
+ entry(digest) {
112
+ assertDigest(digest);
113
+ const directory = path.join(this.root, digest);
114
+ const file = path.join(directory, CACHE_FILE);
115
+ const stat = fs.existsSync(file) ? fs.statSync(file) : undefined;
116
+ return {
117
+ digest,
118
+ directory,
119
+ file,
120
+ bytes: stat?.size ?? 0,
121
+ accessedAt: Math.max(stat?.atimeMs ?? 0, stat?.mtimeMs ?? 0),
122
+ };
123
+ }
124
+ async lock(digest, signal) {
125
+ const target = path.join(this.root, `.${digest}.lock`);
126
+ const deadline = Date.now() + this.lockTimeoutMs;
127
+ while (true) {
128
+ try {
129
+ const descriptor = fs.openSync(target, "wx", 0o600);
130
+ fs.writeFileSync(descriptor, `${process.pid}\n${Date.now()}\n`);
131
+ fs.closeSync(descriptor);
132
+ return () => fs.rmSync(target, { force: true });
133
+ }
134
+ catch (error) {
135
+ if (error.code !== "EEXIST")
136
+ throw error;
137
+ try {
138
+ const [pidText, createdText] = fs.readFileSync(target, "utf8").split("\n");
139
+ if (Date.now() - Number(createdText) > this.lockTimeoutMs &&
140
+ !processExists(Number(pidText))) {
141
+ fs.rmSync(target, { force: true });
142
+ continue;
143
+ }
144
+ }
145
+ catch {
146
+ // The lock owner may be replacing or releasing it; retry within the bound.
147
+ }
148
+ if (Date.now() >= deadline)
149
+ throw new SharedIndexError("timeout", "shared-index cache lock timed out");
150
+ await wait(50, signal);
151
+ }
152
+ }
153
+ }
154
+ valid(entry, expectedBytes, expectedDigest) {
155
+ if (!fs.existsSync(entry.directory))
156
+ return false;
157
+ const directory = fs.lstatSync(entry.directory);
158
+ if (!directory.isDirectory() || directory.isSymbolicLink())
159
+ return false;
160
+ if (!fs.existsSync(entry.file))
161
+ return false;
162
+ const stat = fs.lstatSync(entry.file);
163
+ if (!stat.isFile() ||
164
+ stat.isSymbolicLink() ||
165
+ stat.size !== expectedBytes ||
166
+ (stat.mode & 0o777) !== 0o400)
167
+ return false;
168
+ return hashFile(entry.file) === expectedDigest;
169
+ }
170
+ lease(entry, state) {
171
+ const marker = path.join(entry.directory, `.lease-${process.pid}-${crypto.randomUUID()}`);
172
+ fs.writeFileSync(marker, "", { flag: "wx", mode: 0o600 });
173
+ const now = new Date();
174
+ fs.utimesSync(entry.file, now, now);
175
+ let released = false;
176
+ return {
177
+ path: entry.file,
178
+ state,
179
+ release: () => {
180
+ if (released)
181
+ return;
182
+ released = true;
183
+ fs.rmSync(marker, { force: true });
184
+ this.evict();
185
+ },
186
+ };
187
+ }
188
+ entries() {
189
+ return fs
190
+ .readdirSync(this.root, { withFileTypes: true })
191
+ .filter((entry) => entry.isDirectory() && DIGEST.test(entry.name))
192
+ .map((entry) => this.entry(entry.name))
193
+ .filter((entry) => fs.existsSync(entry.file));
194
+ }
195
+ evict() {
196
+ const entries = this.entries().sort((left, right) => left.accessedAt - right.accessedAt);
197
+ let bytes = entries.reduce((total, entry) => total + entry.bytes, 0);
198
+ let count = entries.length;
199
+ for (const entry of entries) {
200
+ if (count <= this.maxEntries && bytes <= this.maxBytes)
201
+ break;
202
+ if (isLeased(entry.directory) || fs.existsSync(path.join(this.root, `.${entry.digest}.lock`)))
203
+ continue;
204
+ fs.rmSync(entry.directory, { recursive: true, force: true });
205
+ count -= 1;
206
+ bytes -= entry.bytes;
207
+ }
208
+ }
209
+ async acquire(store, manifest, signal) {
210
+ const digest = manifest.graph.compressedSha256;
211
+ const expectedBytes = manifest.graph.compressedBytes;
212
+ if (expectedBytes > this.maxBytes)
213
+ throw new SharedIndexError("configuration", "shared-index artifact exceeds the configured cache-size bound");
214
+ const entry = this.entry(digest);
215
+ const unlock = await this.lock(digest, signal);
216
+ try {
217
+ const corrupt = fs.existsSync(entry.file);
218
+ if (this.valid(entry, expectedBytes, digest))
219
+ return this.lease(entry, "hit");
220
+ if (corrupt && isLeased(entry.directory))
221
+ throw new SharedIndexError("unavailable", "shared-index corrupt cache entry is currently leased");
222
+ if (fs.existsSync(entry.directory))
223
+ fs.rmSync(entry.directory, { recursive: true, force: true });
224
+ fs.mkdirSync(entry.directory, { recursive: true, mode: 0o700 });
225
+ const metadata = await store.head(manifest.graph.key, { signal });
226
+ if (metadata.bytes !== expectedBytes)
227
+ throw new SharedIndexError("integrity", "shared-index compressed object size does not match manifest");
228
+ const temporary = path.join(entry.directory, `.download-${crypto.randomUUID()}.tmp`);
229
+ try {
230
+ const source = await store.get(manifest.graph.key, { maxBytes: expectedBytes, signal });
231
+ const descriptor = fs.openSync(temporary, "wx", 0o600);
232
+ const hash = crypto.createHash("sha256");
233
+ let received = 0;
234
+ try {
235
+ for await (const chunk of source) {
236
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
237
+ received += bytes.byteLength;
238
+ if (received > expectedBytes)
239
+ throw new SharedIndexError("integrity", "shared-index compressed object exceeded manifest size");
240
+ hash.update(bytes);
241
+ writeAll(descriptor, bytes);
242
+ }
243
+ fs.fsyncSync(descriptor);
244
+ }
245
+ finally {
246
+ fs.closeSync(descriptor);
247
+ }
248
+ if (received !== expectedBytes || hash.digest("hex") !== digest)
249
+ throw new SharedIndexError("integrity", "shared-index compressed object failed size or digest verification");
250
+ fs.chmodSync(temporary, 0o400);
251
+ fs.renameSync(temporary, entry.file);
252
+ fsyncDirectory(entry.directory);
253
+ const state = corrupt ? "corrupt-refetched" : "downloaded";
254
+ const lease = this.lease(this.entry(digest), state);
255
+ this.evict();
256
+ return lease;
257
+ }
258
+ catch (error) {
259
+ fs.rmSync(temporary, { force: true });
260
+ throw error;
261
+ }
262
+ }
263
+ finally {
264
+ unlock();
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,70 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL_ID, EMBEDDING_MODEL_REVISION, } from "../engine/embeddings.js";
6
+ import { KNODIN_SCHEMA_VERSION } from "../engine/index.js";
7
+ import { prunePolicyDescriptor } from "../engine/prune.js";
8
+ import { KNODIN_VERSION } from "../version.js";
9
+ import { canonicalManifestBytes, sha256Hex } from "./contract.js";
10
+ const require = createRequire(import.meta.url);
11
+ function packageVersion(name) {
12
+ try {
13
+ return require(`${name}/package.json`).version;
14
+ }
15
+ catch {
16
+ let current = path.dirname(fileURLToPath(import.meta.url));
17
+ try {
18
+ current = path.dirname(require.resolve(name));
19
+ }
20
+ catch {
21
+ throw new Error(`cannot resolve compatibility dependency ${name}`);
22
+ }
23
+ for (;;) {
24
+ const manifest = path.join(current, "package.json");
25
+ if (fs.existsSync(manifest)) {
26
+ const value = JSON.parse(fs.readFileSync(manifest, "utf8"));
27
+ if (value.name === name && value.version)
28
+ return value.version;
29
+ }
30
+ const parent = path.dirname(current);
31
+ if (parent === current)
32
+ break;
33
+ current = parent;
34
+ }
35
+ throw new Error(`cannot read compatibility dependency ${name}`);
36
+ }
37
+ }
38
+ function digest(value) {
39
+ return sha256Hex(canonicalManifestBytes(value));
40
+ }
41
+ /** One implementation used by client verification and publisher metadata. */
42
+ export function currentSharedIndexCompatibility() {
43
+ const model = {
44
+ id: EMBEDDING_MODEL_ID,
45
+ revision: EMBEDDING_MODEL_REVISION,
46
+ dimensions: EMBEDDING_DIMENSIONS,
47
+ runtime: `@huggingface/transformers@${packageVersion("@huggingface/transformers")}`,
48
+ };
49
+ const parsers = {
50
+ policyVersion: 1,
51
+ packages: {
52
+ "tree-sitter-wasms": packageVersion("tree-sitter-wasms"),
53
+ "web-tree-sitter": packageVersion("web-tree-sitter"),
54
+ "web-tree-sitter-sfapex": packageVersion("web-tree-sitter-sfapex"),
55
+ },
56
+ };
57
+ return {
58
+ knodinVersion: KNODIN_VERSION,
59
+ databaseSchemaVersion: KNODIN_SCHEMA_VERSION,
60
+ modelId: EMBEDDING_MODEL_ID,
61
+ modelDigest: digest(model),
62
+ parserBundleDigest: digest(parsers),
63
+ indexConfigDigest: digest({
64
+ policyVersion: 1,
65
+ databaseSchemaVersion: KNODIN_SCHEMA_VERSION,
66
+ embeddingDimensions: EMBEDDING_DIMENSIONS,
67
+ }),
68
+ prunePolicyDigest: digest(prunePolicyDescriptor()),
69
+ };
70
+ }
@@ -0,0 +1,224 @@
1
+ import child_process from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { parseDocument, stringify } from "yaml";
6
+ import { z } from "zod";
7
+ import { canonicalRepositoryIdentity, sharedBranchMatches, } from "./contract.js";
8
+ export const SHARED_INDEX_CONFIG_PATH = ".knodin/shared-index.yaml";
9
+ const repositorySchema = z
10
+ .object({
11
+ host: z.string().min(1).max(253),
12
+ owner: z.string().min(1).max(255),
13
+ name: z.string().min(1).max(255),
14
+ })
15
+ .strict();
16
+ const canonicalHost = /^(?!.*\.\.)(?!.*(?:^|\.)-)(?!.*-(?:\.|$))[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
17
+ const canonicalOwner = /^[a-z0-9](?:[a-z0-9-]{0,253}[a-z0-9])?$/;
18
+ const canonicalName = /^[a-z0-9](?:[a-z0-9._-]{0,253}[a-z0-9])?$/;
19
+ const configSchema = z
20
+ .object({
21
+ schemaVersion: z.literal(1),
22
+ enabled: z.boolean(),
23
+ registrationId: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,127}$/),
24
+ aws: z
25
+ .object({
26
+ region: z.string().regex(/^[a-z]{2}(?:-gov)?-[a-z]+-\d$/),
27
+ bucket: z
28
+ .string()
29
+ .regex(/^(?!xn--)(?!.*\.\.)(?!.*\.-)(?!.*-\.)(?!\d{1,3}(?:\.\d{1,3}){3}$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/),
30
+ })
31
+ .strict(),
32
+ repository: repositorySchema,
33
+ branches: z.array(z.string().min(1).max(255)).min(1).max(64),
34
+ signing: z
35
+ .object({
36
+ algorithm: z.literal("RSASSA_PSS_SHA_256"),
37
+ keyId: z
38
+ .string()
39
+ .max(2048)
40
+ .regex(/^(?:arn:(?:aws|aws-us-gov|aws-cn):kms:[a-z0-9-]+:\d{12}:(?:key\/[A-Za-z0-9-]+|alias\/[A-Za-z0-9/_+=,.@-]+)|alias\/[A-Za-z0-9/_+=,.@-]+)$/),
41
+ publicKeySha256: z.string().regex(/^[a-f0-9]{64}$/),
42
+ publicKeyPem: z.string().min(1).max(16_384),
43
+ })
44
+ .strict(),
45
+ })
46
+ .strict();
47
+ function boundedError(error) {
48
+ if (error instanceof z.ZodError)
49
+ return error.issues
50
+ .slice(0, 16)
51
+ .map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
52
+ .join("; ");
53
+ if (error instanceof Error)
54
+ return error.message.slice(0, 512);
55
+ return "invalid shared-index configuration";
56
+ }
57
+ function validateBranchPattern(pattern) {
58
+ if (pattern.startsWith("/") ||
59
+ pattern.endsWith("/") ||
60
+ pattern.includes("//") ||
61
+ pattern.includes("..") ||
62
+ pattern.includes("@{") ||
63
+ /[~^:?[\\]/.test(pattern))
64
+ return false;
65
+ const stars = [...pattern].filter((character) => character === "*").length;
66
+ return stars === 0 || (stars === 1 && pattern.endsWith("/*"));
67
+ }
68
+ export function publicKeyFingerprint(publicKeyPem) {
69
+ const key = crypto.createPublicKey(publicKeyPem);
70
+ if (key.asymmetricKeyType !== "rsa" && key.asymmetricKeyType !== "rsa-pss")
71
+ throw new Error("signing public key must be RSA");
72
+ const bits = key.asymmetricKeyDetails?.modulusLength;
73
+ if (!bits || bits < 2048 || bits > 8192)
74
+ throw new Error("signing RSA public key must have a 2048-8192 bit modulus");
75
+ const der = key.export({ type: "spki", format: "der" });
76
+ return crypto.createHash("sha256").update(der).digest("hex");
77
+ }
78
+ function parseRemote(remote) {
79
+ const trimmed = remote.trim();
80
+ let host;
81
+ let pathname;
82
+ try {
83
+ if (/^[^@/\s]+@[^:/\s]+:.+$/.test(trimmed)) {
84
+ const match = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(trimmed);
85
+ if (!match)
86
+ return null;
87
+ host = match[1];
88
+ pathname = match[2];
89
+ }
90
+ else {
91
+ const parsed = new URL(trimmed);
92
+ host = parsed.hostname;
93
+ pathname = parsed.pathname.replace(/^\//, "");
94
+ }
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ const segments = pathname.split("/").filter(Boolean);
100
+ if (segments.length !== 2)
101
+ return null;
102
+ return canonicalRepositoryIdentity({ host, owner: segments[0], name: segments[1] });
103
+ }
104
+ export function repositoryRemoteIdentities(repo) {
105
+ let output;
106
+ try {
107
+ output = child_process.execFileSync("git", ["remote", "-v"], {
108
+ cwd: repo,
109
+ encoding: "utf8",
110
+ stdio: ["ignore", "pipe", "ignore"],
111
+ });
112
+ }
113
+ catch {
114
+ return [];
115
+ }
116
+ const identities = new Map();
117
+ for (const line of output.split("\n")) {
118
+ const remote = line.trim().split(/\s+/)[1];
119
+ if (!remote)
120
+ continue;
121
+ const identity = parseRemote(remote);
122
+ if (!identity)
123
+ continue;
124
+ identities.set(`${identity.host}/${identity.owner}/${identity.name}`, identity);
125
+ }
126
+ return [...identities.values()];
127
+ }
128
+ export function validateSharedIndexConfig(value, repo) {
129
+ const parsed = configSchema.parse(value);
130
+ const repository = canonicalRepositoryIdentity(parsed.repository);
131
+ if (!canonicalHost.test(repository.host))
132
+ throw new Error("repository host is not canonical DNS syntax");
133
+ if (!canonicalOwner.test(repository.owner))
134
+ throw new Error("repository owner is invalid");
135
+ if (!canonicalName.test(repository.name))
136
+ throw new Error("repository name is invalid");
137
+ if (new Set(parsed.branches).size !== parsed.branches.length)
138
+ throw new Error("branches must not contain duplicates");
139
+ if (parsed.branches.some((branch) => !validateBranchPattern(branch)))
140
+ throw new Error("branch must be an exact Git ref or a terminal slash-star prefix pattern");
141
+ // Exercise the frozen matcher for every accepted pattern so validation and
142
+ // selection cannot silently drift into two different glob languages.
143
+ for (const branch of parsed.branches)
144
+ if (!branch.endsWith("/*") && !sharedBranchMatches(branch, branch))
145
+ throw new Error("invalid exact branch pattern");
146
+ const fingerprint = publicKeyFingerprint(parsed.signing.publicKeyPem);
147
+ if (!crypto.timingSafeEqual(Buffer.from(fingerprint, "hex"), Buffer.from(parsed.signing.publicKeySha256, "hex")))
148
+ throw new Error("signing public-key fingerprint does not match reviewed configuration");
149
+ if (repo) {
150
+ const remotes = repositoryRemoteIdentities(repo);
151
+ if (remotes.length === 0)
152
+ throw new Error("repository identity cannot be verified because no supported Git remote exists");
153
+ if (!remotes.some((remote) => JSON.stringify(remote) === JSON.stringify(repository)))
154
+ throw new Error("configured repository identity does not match any local Git remote");
155
+ }
156
+ return { ...parsed, repository };
157
+ }
158
+ export function loadSharedIndexConfig(repo) {
159
+ const target = path.join(path.resolve(repo), SHARED_INDEX_CONFIG_PATH);
160
+ if (!fs.existsSync(target))
161
+ return { state: "not-configured", path: target };
162
+ try {
163
+ const document = parseDocument(fs.readFileSync(target, "utf8"), {
164
+ strict: true,
165
+ uniqueKeys: true,
166
+ });
167
+ if (document.errors.length > 0)
168
+ throw new Error(document.errors.map((error) => error.message).join("; "));
169
+ const raw = document.toJS({ maxAliasCount: 0 });
170
+ return { state: "configured", path: target, config: validateSharedIndexConfig(raw, repo) };
171
+ }
172
+ catch (error) {
173
+ return { state: "invalid", path: target, errors: [boundedError(error)] };
174
+ }
175
+ }
176
+ function assertWritableTarget(repo, target) {
177
+ const state = path.dirname(target);
178
+ if (fs.existsSync(state) && fs.lstatSync(state).isSymbolicLink())
179
+ throw new Error("shared-index configuration directory must not be a symbolic link");
180
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink())
181
+ throw new Error("shared-index configuration must not be a symbolic link");
182
+ const relative = path.relative(path.resolve(repo), path.resolve(target));
183
+ if (relative.startsWith("..") || path.isAbsolute(relative))
184
+ throw new Error("shared-index configuration must remain inside the repository");
185
+ }
186
+ export function writeSharedIndexConfig(repo, config) {
187
+ const resolved = path.resolve(repo);
188
+ const validated = validateSharedIndexConfig(config, resolved);
189
+ const target = path.join(resolved, SHARED_INDEX_CONFIG_PATH);
190
+ assertWritableTarget(resolved, target);
191
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
192
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
193
+ try {
194
+ fs.writeFileSync(temporary, stringify(validated, { lineWidth: 0 }), {
195
+ mode: 0o600,
196
+ flag: "wx",
197
+ });
198
+ const descriptor = fs.openSync(temporary, "r");
199
+ try {
200
+ fs.fsyncSync(descriptor);
201
+ }
202
+ finally {
203
+ fs.closeSync(descriptor);
204
+ }
205
+ fs.renameSync(temporary, target);
206
+ try {
207
+ const directory = fs.openSync(path.dirname(target), "r");
208
+ try {
209
+ fs.fsyncSync(directory);
210
+ }
211
+ finally {
212
+ fs.closeSync(directory);
213
+ }
214
+ }
215
+ catch (error) {
216
+ if (process.platform !== "win32")
217
+ throw error;
218
+ }
219
+ }
220
+ finally {
221
+ fs.rmSync(temporary, { force: true });
222
+ }
223
+ return target;
224
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash } from "node:crypto";
2
+ export const SHARED_INDEX_SCHEMA_VERSION = 1;
3
+ export const SHARED_INDEX_SIGNATURE = {
4
+ algorithm: "RSA-PSS",
5
+ hash: "sha256",
6
+ mgf1Hash: "sha256",
7
+ saltLength: 32,
8
+ input: "canonical-manifest-bytes",
9
+ };
10
+ export const SHARED_INDEX_LIMITS = {
11
+ pointerBytes: 64 * 1024,
12
+ manifestBytes: 256 * 1024,
13
+ signatureBytes: 16 * 1024,
14
+ compressedBytes: 8 * 1024 ** 3,
15
+ uncompressedBytes: 32 * 1024 ** 3,
16
+ };
17
+ function normalizedComponent(value) {
18
+ return value.normalize("NFC").toLocaleLowerCase("en-US");
19
+ }
20
+ /** Canonical identity used for remote comparison, signatures, and S3 prefixes. */
21
+ export function canonicalRepositoryIdentity(identity) {
22
+ const host = identity.host
23
+ .replace(/^https?:\/\//i, "")
24
+ .replace(/^ssh:\/\/git@/i, "")
25
+ .replace(/\/$/, "")
26
+ .replace(/\.$/, "");
27
+ return {
28
+ host: normalizedComponent(host),
29
+ owner: normalizedComponent(identity.owner),
30
+ name: normalizedComponent(identity.name.replace(/\.git$/i, "")),
31
+ };
32
+ }
33
+ /** RFC 3986 component encoding (encodeURIComponent leaves five extra characters). */
34
+ export function encodeSharedIndexComponent(value) {
35
+ return encodeURIComponent(value.normalize("NFC")).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
36
+ }
37
+ export function sharedRepositoryPrefix(identity) {
38
+ const canonical = canonicalRepositoryIdentity(identity);
39
+ return `v1/${encodeSharedIndexComponent(canonical.host)}/${encodeSharedIndexComponent(canonical.owner)}/${encodeSharedIndexComponent(canonical.name)}/`;
40
+ }
41
+ function parsedVersion(version) {
42
+ const match = /^(\d+)\.(\d+)\.\d+(?:-([0-9A-Za-z.-]+))?$/.exec(version);
43
+ if (!match)
44
+ return null;
45
+ return { major: Number(match[1]), minor: Number(match[2]), prerelease: match[3] };
46
+ }
47
+ /** Exact v1 compatibility decision; every rejection is bounded and source-free. */
48
+ export function sharedIndexCompatibility(client, manifest) {
49
+ const reasons = [];
50
+ const clientVersion = parsedVersion(client.knodinVersion);
51
+ const manifestVersion = parsedVersion(manifest.knodinVersion);
52
+ if (!clientVersion || !manifestVersion)
53
+ reasons.push("invalid-knodin-version");
54
+ else if (clientVersion.prerelease || manifestVersion.prerelease) {
55
+ if (client.knodinVersion !== manifest.knodinVersion)
56
+ reasons.push("knodin-prerelease-mismatch");
57
+ }
58
+ else if (clientVersion.major !== manifestVersion.major ||
59
+ clientVersion.minor < manifestVersion.minor) {
60
+ reasons.push("knodin-version-incompatible");
61
+ }
62
+ const exactFields = [
63
+ "databaseSchemaVersion",
64
+ "modelId",
65
+ "modelDigest",
66
+ "parserBundleDigest",
67
+ "indexConfigDigest",
68
+ "prunePolicyDigest",
69
+ ];
70
+ for (const field of exactFields) {
71
+ if (client[field] !== manifest[field])
72
+ reasons.push(`${field}-mismatch`);
73
+ }
74
+ return { compatible: reasons.length === 0, reasons };
75
+ }
76
+ /** Exact v1 ref matching, including the terminal slash-star prefix form. */
77
+ export function sharedBranchMatches(pattern, branch) {
78
+ const normalizedPattern = pattern.normalize("NFC");
79
+ const normalizedBranch = branch.normalize("NFC");
80
+ return normalizedPattern.endsWith("/*")
81
+ ? normalizedBranch.startsWith(normalizedPattern.slice(0, -1)) &&
82
+ normalizedBranch.length > normalizedPattern.length - 1
83
+ : normalizedBranch === normalizedPattern;
84
+ }
85
+ function canonicalValue(value) {
86
+ if (typeof value === "string" &&
87
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value))
88
+ throw new TypeError("canonical JSON forbids unpaired Unicode surrogates");
89
+ if (value === null || typeof value === "boolean" || typeof value === "string")
90
+ return JSON.stringify(value);
91
+ if (typeof value === "number") {
92
+ if (!Number.isFinite(value))
93
+ throw new TypeError("canonical JSON forbids non-finite numbers");
94
+ return JSON.stringify(value);
95
+ }
96
+ if (Array.isArray(value))
97
+ return `[${value.map(canonicalValue).join(",")}]`;
98
+ if (typeof value === "object") {
99
+ const record = value;
100
+ const entries = Object.keys(record)
101
+ .sort((left, right) => left.localeCompare(right))
102
+ .map((key) => {
103
+ if (record[key] === undefined)
104
+ throw new TypeError("canonical JSON forbids undefined object values");
105
+ return `${JSON.stringify(key)}:${canonicalValue(record[key])}`;
106
+ });
107
+ return `{${entries.join(",")}}`;
108
+ }
109
+ throw new TypeError(`canonical JSON forbids ${typeof value} values`);
110
+ }
111
+ /** Exact UTF-8 bytes hashed and signed by shared-index v1. */
112
+ export function canonicalManifestBytes(manifest) {
113
+ return Buffer.from(canonicalValue(manifest), "utf8");
114
+ }
115
+ export function sha256Hex(bytes) {
116
+ return createHash("sha256").update(bytes).digest("hex");
117
+ }