@trylucy/agent-memory 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +205 -2
- package/contracts/agent-manifest.json +27 -0
- package/contracts/agent-memory-api.contract.json +199 -0
- package/contracts/hashes.json +23 -0
- package/package.json +84 -4
- package/schemas/canonical-recall/response.schema.json +56 -0
- package/schemas/v1/common.schema.json +190 -0
- package/schemas/v1/contract-document.schema.json +277 -0
- package/schemas/v1/create-proposal-request.schema.json +42 -0
- package/schemas/v1/error.schema.json +90 -0
- package/schemas/v1/memory-version-state.schema.json +39 -0
- package/schemas/v1/memory.schema.json +47 -0
- package/schemas/v1/proposal-status.schema.json +277 -0
- package/schemas/v1/public-surface.schema.json +26 -0
- package/schemas/v1/recall-request.schema.json +45 -0
- package/schemas/v1/recall-response.schema.json +60 -0
- package/schemas/v1/receipt.schema.json +67 -0
- package/schemas/v1/replace-proposal-request.schema.json +46 -0
- package/schemas/v1/replace-transition.schema.json +19 -0
- package/schemas/v1/scope.schema.json +10 -0
- package/scripts/lib/am01.mjs +159 -0
- package/src/client/canonical-validation.mjs +37 -0
- package/src/client/client.mjs +151 -0
- package/src/client/errors.mjs +101 -0
- package/src/client/http-transport.d.ts +12 -0
- package/src/client/http-transport.mjs +212 -0
- package/src/client/index.d.ts +243 -0
- package/src/client/index.mjs +38 -0
- package/src/client/serialization.mjs +53 -0
- package/src/client/validation.mjs +174 -0
- package/src/clients/claude-desktop.json +13 -0
- package/src/clients/claude.json +13 -0
- package/src/clients/codex.toml +8 -0
- package/src/clients/cursor.json +12 -0
- package/src/clients/generic.mcp.json +12 -0
- package/src/clients/openclaw.json +14 -0
- package/src/clients/vscode.json +13 -0
- package/src/integrations/hermes-advisory-lock.mjs +140 -0
- package/src/integrations/hermes-artifact.mjs +551 -0
- package/src/integrations/hermes-filesystem.mjs +112 -0
- package/src/integrations/hermes.d.ts +106 -0
- package/src/integrations/hermes.mjs +425 -0
- package/src/mcp/bin.mjs +4 -0
- package/src/mcp/config.mjs +60 -0
- package/src/mcp/index.d.ts +27 -0
- package/src/mcp/lifecycle.mjs +50 -0
- package/src/mcp/schemas.mjs +98 -0
- package/src/mcp/server.mjs +163 -0
- package/src/mcp/stdio.d.ts +28 -0
- package/src/mcp/stdio.mjs +59 -0
- package/src/onboarding/usage.mjs +44 -0
- package/src/projection/memory-md.mjs +319 -0
- package/src/provider/index.d.ts +9 -0
- package/src/provider/index.mjs +1 -0
- package/src/provider/memory-first.mjs +95 -0
- package/src/routing/destination.mjs +42 -0
- package/src/safety/secrets.mjs +31 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
fstatSync,
|
|
6
|
+
fchmodSync,
|
|
7
|
+
fsyncSync,
|
|
8
|
+
linkSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
statSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { basename, dirname, join, sep } from 'node:path';
|
|
22
|
+
import { acquireHermesAdvisoryLock } from './hermes-advisory-lock.mjs';
|
|
23
|
+
import {
|
|
24
|
+
assertAbsolute,
|
|
25
|
+
assertEffectiveOwner,
|
|
26
|
+
assertInside,
|
|
27
|
+
assertTrustedOwner,
|
|
28
|
+
validateContainedDirectory,
|
|
29
|
+
validateContainedParent,
|
|
30
|
+
validateSafeDirectoryChain,
|
|
31
|
+
} from './hermes-filesystem.mjs';
|
|
32
|
+
|
|
33
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
34
|
+
const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
35
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
36
|
+
const VERSION = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
37
|
+
const SHA = /^[a-f0-9]{40,64}$/;
|
|
38
|
+
const PACKAGE_NAME = '@trylucy/agent-memory';
|
|
39
|
+
const PACKAGE_REPOSITORY = 'https://github.com/Lucy-AI-Inc/lucy-memory-mcp.git';
|
|
40
|
+
const SOURCE_COPY_ROOTS = ['src', 'schemas', 'contracts', 'scripts/lib'];
|
|
41
|
+
const ARTIFACT_COPY_ROOTS = [...SOURCE_COPY_ROOTS, 'node_modules'];
|
|
42
|
+
const TRACKED_COPY_ROOTS = SOURCE_COPY_ROOTS;
|
|
43
|
+
const TRUSTED_GIT_PATH = '/usr/bin/git';
|
|
44
|
+
const SCRUBBED_TOOL_ENV = Object.freeze({
|
|
45
|
+
PATH: '/usr/bin:/bin',
|
|
46
|
+
LANG: 'C',
|
|
47
|
+
LC_ALL: 'C',
|
|
48
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
49
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
50
|
+
GIT_NO_REPLACE_OBJECTS: '1',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function digest(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
54
|
+
function regular(path, label, required = true) {
|
|
55
|
+
let stat;
|
|
56
|
+
try { stat = lstatSync(path); } catch (error) {
|
|
57
|
+
if (!required && error?.code === 'ENOENT') return null;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (stat.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link.`);
|
|
61
|
+
if (!stat.isFile()) throw new Error(`${label} must be a regular file.`);
|
|
62
|
+
return stat;
|
|
63
|
+
}
|
|
64
|
+
function privateDirectory(path, label) {
|
|
65
|
+
const absolute = assertAbsolute(path, label);
|
|
66
|
+
validateSafeDirectoryChain(dirname(absolute), `${label} parent`, { allowMissing: true });
|
|
67
|
+
const parts = absolute.split(sep).filter(Boolean);
|
|
68
|
+
let current = sep;
|
|
69
|
+
for (const part of parts) {
|
|
70
|
+
current = join(current, part);
|
|
71
|
+
try {
|
|
72
|
+
const stat = lstatSync(current);
|
|
73
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a directory without symbolic links.`);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
76
|
+
mkdirSync(current, { mode: PRIVATE_DIRECTORY_MODE });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
validateSafeDirectoryChain(absolute, label, { requireLeafOwner: true });
|
|
80
|
+
chmodSync(absolute, PRIVATE_DIRECTORY_MODE);
|
|
81
|
+
return absolute;
|
|
82
|
+
}
|
|
83
|
+
function privateProfile(path) {
|
|
84
|
+
const absolute = assertAbsolute(path, 'Hermes profile');
|
|
85
|
+
const stat = lstatSync(absolute, { throwIfNoEntry: false });
|
|
86
|
+
if (!stat) return privateDirectory(absolute, 'Hermes profile');
|
|
87
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('Hermes profile must be a directory without symbolic links.');
|
|
88
|
+
validateSafeDirectoryChain(absolute, 'Hermes profile', { requireLeafOwner: true });
|
|
89
|
+
assertEffectiveOwner(stat, 'Hermes profile');
|
|
90
|
+
if ((stat.mode & 0o077) !== 0) throw new Error('An existing Hermes profile must already be private; refusing to change its permissions.');
|
|
91
|
+
return absolute;
|
|
92
|
+
}
|
|
93
|
+
function syncDirectory(path) {
|
|
94
|
+
let fd;
|
|
95
|
+
try { fd = openSync(path, constants.O_RDONLY); fsyncSync(fd); }
|
|
96
|
+
catch (error) { if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM'].includes(error?.code)) throw error; }
|
|
97
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
98
|
+
}
|
|
99
|
+
function atomicWrite(path, contents, label) {
|
|
100
|
+
const parent = privateDirectory(dirname(path), `${label} parent`);
|
|
101
|
+
const existingFile = regular(path, label, false);
|
|
102
|
+
if (existingFile) {
|
|
103
|
+
assertEffectiveOwner(existingFile, label);
|
|
104
|
+
if (existingFile.nlink !== 1) throw new Error(`${label} must not be hard linked.`);
|
|
105
|
+
}
|
|
106
|
+
const temporary = join(parent, `.${basename(path)}.${randomBytes(16).toString('hex')}.tmp`);
|
|
107
|
+
let fd; let exists = false;
|
|
108
|
+
try {
|
|
109
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW, PRIVATE_FILE_MODE);
|
|
110
|
+
exists = true; writeFileSync(fd, contents); fchmodSync(fd, PRIVATE_FILE_MODE); fsyncSync(fd); closeSync(fd); fd = undefined;
|
|
111
|
+
regular(path, label, false); renameSync(temporary, path); exists = false; syncDirectory(parent);
|
|
112
|
+
} finally {
|
|
113
|
+
if (fd !== undefined) closeSync(fd);
|
|
114
|
+
if (exists) rmSync(temporary, { force: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function packageRoot(sourcePath) {
|
|
118
|
+
let current = dirname(assertAbsolute(sourcePath, 'Artifact source'));
|
|
119
|
+
while (dirname(current) !== current) {
|
|
120
|
+
const candidate = join(current, 'package.json');
|
|
121
|
+
if (regular(candidate, 'Package manifest', false)) return current;
|
|
122
|
+
current = dirname(current);
|
|
123
|
+
}
|
|
124
|
+
throw new Error('Artifact source must be inside an official package with package.json.');
|
|
125
|
+
}
|
|
126
|
+
function trustedGit() {
|
|
127
|
+
let stat;
|
|
128
|
+
try { stat = lstatSync(TRUSTED_GIT_PATH); } catch { throw new Error(`Trusted Git executable is unavailable at ${TRUSTED_GIT_PATH}.`); }
|
|
129
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) {
|
|
130
|
+
throw new Error('Trusted Git executable must be a non-writable regular executable.');
|
|
131
|
+
}
|
|
132
|
+
assertTrustedOwner(stat, 'Trusted Git executable');
|
|
133
|
+
validateSafeDirectoryChain(dirname(TRUSTED_GIT_PATH), 'Trusted Git executable parent');
|
|
134
|
+
return TRUSTED_GIT_PATH;
|
|
135
|
+
}
|
|
136
|
+
function git(root, args, label) {
|
|
137
|
+
const result = spawnSync(trustedGit(), ['-C', root, '--no-replace-objects', ...args], { encoding: 'utf8', env: SCRUBBED_TOOL_ENV });
|
|
138
|
+
if (result.status !== 0) throw new Error(`Unable to determine ${label} from the source checkout.`);
|
|
139
|
+
return result.stdout.trim();
|
|
140
|
+
}
|
|
141
|
+
function gitBytes(root, args, label, input) {
|
|
142
|
+
const result = spawnSync(trustedGit(), ['-C', root, '--no-replace-objects', ...args], { input, maxBuffer: 128 * 1024 * 1024, env: SCRUBBED_TOOL_ENV });
|
|
143
|
+
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) throw new Error(`Unable to determine ${label} from the source checkout.`);
|
|
144
|
+
return result.stdout;
|
|
145
|
+
}
|
|
146
|
+
function commitId(root, revision, label) {
|
|
147
|
+
const result = spawnSync(trustedGit(), ['-C', root, '--no-replace-objects', 'rev-parse', '--verify', `${revision}^{commit}`], { encoding: 'utf8', env: SCRUBBED_TOOL_ENV });
|
|
148
|
+
const commit = result.status === 0 ? result.stdout.trim() : '';
|
|
149
|
+
if (!SHA.test(commit)) throw new Error(`${label} must resolve to a full commit ID.`);
|
|
150
|
+
return commit;
|
|
151
|
+
}
|
|
152
|
+
function canonicalRepository(value) {
|
|
153
|
+
return String(value ?? '').replace(/^git\+/, '').replace(/\.git$/, '').toLowerCase();
|
|
154
|
+
}
|
|
155
|
+
function provenance(root, expectedSourceGitSha) {
|
|
156
|
+
const sourceGitSha = commitId(root, 'HEAD', 'Source Git SHA');
|
|
157
|
+
const remote = git(root, ['config', '--get', 'remote.origin.url'], 'source repository');
|
|
158
|
+
if (canonicalRepository(remote) !== canonicalRepository(PACKAGE_REPOSITORY)) throw new Error('Source checkout remote does not match the expected Lucy MCP repository.');
|
|
159
|
+
if (typeof expectedSourceGitSha !== 'string' || !SHA.test(expectedSourceGitSha)) throw new Error('expectedSourceGitSha must be an explicit full commit ID.');
|
|
160
|
+
const expectedCommit = commitId(root, expectedSourceGitSha, 'expectedSourceGitSha');
|
|
161
|
+
if (expectedCommit !== expectedSourceGitSha) throw new Error('expectedSourceGitSha must name a commit object directly.');
|
|
162
|
+
if (sourceGitSha !== expectedCommit) throw new Error('Source checkout HEAD does not match expectedSourceGitSha.');
|
|
163
|
+
return { source_repository: PACKAGE_REPOSITORY, source_git_sha: sourceGitSha };
|
|
164
|
+
}
|
|
165
|
+
function packageProvenance(manifest) {
|
|
166
|
+
if (!manifest || manifest.name !== PACKAGE_NAME || canonicalRepository(manifest.repository?.url) !== canonicalRepository(PACKAGE_REPOSITORY)
|
|
167
|
+
|| typeof manifest.version !== 'string' || !manifest.version) throw new Error('Artifact source package identity does not match the expected Lucy MCP package.');
|
|
168
|
+
return { package_name: manifest.name, package_version: manifest.version, source_repository: PACKAGE_REPOSITORY };
|
|
169
|
+
}
|
|
170
|
+
function checkedTree(root, relativePath, files) {
|
|
171
|
+
const absolute = join(root, relativePath);
|
|
172
|
+
const stat = lstatSync(absolute);
|
|
173
|
+
if (stat.isSymbolicLink()) throw new Error(`Artifact closure rejects symbolic link: ${relativePath}`);
|
|
174
|
+
if (stat.isFile()) { files.push(relativePath); return; }
|
|
175
|
+
if (!stat.isDirectory()) throw new Error(`Artifact closure rejects special file: ${relativePath}`);
|
|
176
|
+
for (const name of readdirSync(absolute).sort()) {
|
|
177
|
+
if (relativePath === 'node_modules' && name === '.bin') continue;
|
|
178
|
+
checkedTree(root, join(relativePath, name), files);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function closureFiles(root, roots = ARTIFACT_COPY_ROOTS) {
|
|
182
|
+
const files = ['package.json', 'package-lock.json'];
|
|
183
|
+
for (const file of files) regular(join(root, file), `Artifact closure ${file}`);
|
|
184
|
+
for (const path of roots) {
|
|
185
|
+
if (!lstatSync(join(root, path), { throwIfNoEntry: false })) throw new Error(`Artifact source closure is missing ${path}.`);
|
|
186
|
+
checkedTree(root, path, files);
|
|
187
|
+
}
|
|
188
|
+
return files.sort();
|
|
189
|
+
}
|
|
190
|
+
function closureDigest(root, files) {
|
|
191
|
+
const hash = createHash('sha256');
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
hash.update(file); hash.update('\0'); hash.update(readFileSync(join(root, file))); hash.update('\0');
|
|
194
|
+
}
|
|
195
|
+
return hash.digest('hex');
|
|
196
|
+
}
|
|
197
|
+
function trackedSourceFiles(files) { return files.filter((file) => file !== 'node_modules' && !file.startsWith('node_modules/')); }
|
|
198
|
+
function committedTree(root, sourceGitSha) {
|
|
199
|
+
// Enumerate the pinned tree itself, never a list filtered by the live checkout.
|
|
200
|
+
const output = gitBytes(root, ['ls-tree', '-r', '-z', sourceGitSha, '--', 'package.json', 'package-lock.json', ...TRACKED_COPY_ROOTS], 'immutable source tree');
|
|
201
|
+
const entries = new Map(); let offset = 0;
|
|
202
|
+
while (offset < output.length) {
|
|
203
|
+
const end = output.indexOf(0, offset); if (end < 0) throw new Error('Invalid immutable source tree output.');
|
|
204
|
+
const record = output.subarray(offset, end).toString('utf8'); offset = end + 1;
|
|
205
|
+
const match = /^(100(?:644|755)) blob ([a-f0-9]{40,64})\t(.+)$/.exec(record);
|
|
206
|
+
if (!match || entries.has(match[3])) throw new Error('Artifact source tree contains an unsupported entry.');
|
|
207
|
+
entries.set(match[3], match[2]);
|
|
208
|
+
}
|
|
209
|
+
return entries;
|
|
210
|
+
}
|
|
211
|
+
function immutableBlobBytes(root, objectIds) {
|
|
212
|
+
const output = gitBytes(root, ['cat-file', '--batch'], 'immutable source blobs', `${[...new Set(objectIds)].join('\n')}\n`);
|
|
213
|
+
const blobs = new Map(); let offset = 0;
|
|
214
|
+
while (offset < output.length) {
|
|
215
|
+
const headerEnd = output.indexOf(0x0a, offset); if (headerEnd < 0) throw new Error('Invalid immutable source blob output.');
|
|
216
|
+
const header = output.subarray(offset, headerEnd).toString('utf8'); offset = headerEnd + 1;
|
|
217
|
+
const match = /^([a-f0-9]{40,64}) blob (\d+)$/.exec(header); if (!match) throw new Error('Artifact source tree references a missing non-blob object.');
|
|
218
|
+
const size = Number(match[2]); const contents = output.subarray(offset, offset + size);
|
|
219
|
+
if (contents.length !== size || output[offset + size] !== 0x0a) throw new Error('Invalid immutable source blob content.');
|
|
220
|
+
offset += size + 1; blobs.set(match[1], Buffer.from(contents));
|
|
221
|
+
}
|
|
222
|
+
return blobs;
|
|
223
|
+
}
|
|
224
|
+
function digestCommittedClosure(files, blobs) {
|
|
225
|
+
const hash = createHash('sha256');
|
|
226
|
+
for (const file of files) { hash.update(file); hash.update('\0'); hash.update(blobs.get(file)); hash.update('\0'); }
|
|
227
|
+
return hash.digest('hex');
|
|
228
|
+
}
|
|
229
|
+
function verifyCommittedSource(root, files, sourceGitSha) {
|
|
230
|
+
const liveTracked = trackedSourceFiles(files); const tree = committedTree(root, sourceGitSha); const tracked = [...tree.keys()].sort();
|
|
231
|
+
if (tree.size !== liveTracked.length || tracked.some((file, index) => file !== liveTracked[index])) throw new Error('Artifact source closure must exactly match the pinned Git tree.');
|
|
232
|
+
const objects = immutableBlobBytes(root, tree.values()); const blobs = new Map();
|
|
233
|
+
for (const file of tracked) {
|
|
234
|
+
regular(join(root, file), `Committed source input ${file}`);
|
|
235
|
+
const contents = objects.get(tree.get(file)); if (!contents) throw new Error(`Artifact source lacks immutable Git blob for ${file}.`);
|
|
236
|
+
if (digest(readFileSync(join(root, file))) !== digest(contents)) throw new Error(`Artifact source working bytes differ from pinned Git blob: ${file}.`);
|
|
237
|
+
blobs.set(file, contents);
|
|
238
|
+
}
|
|
239
|
+
return { source_git_closure_digest: digestCommittedClosure(tracked, blobs), source_blobs: blobs };
|
|
240
|
+
}
|
|
241
|
+
function parseVersion(value, label) {
|
|
242
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-[0-9A-Za-z.-]+)?$/.exec(String(value));
|
|
243
|
+
if (!match) throw new Error(`${label} must be a semantic version.`);
|
|
244
|
+
return { text: `${Number(match[1])}.${Number(match[2] ?? 0)}.${Number(match[3] ?? 0)}`, parts: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)] };
|
|
245
|
+
}
|
|
246
|
+
function compareVersion(left, right) { for (let index = 0; index < 3; index += 1) if (left.parts[index] !== right.parts[index]) return left.parts[index] > right.parts[index] ? 1 : -1; return 0; }
|
|
247
|
+
function satisfiesRange(version, range, label) {
|
|
248
|
+
if (typeof range !== 'string' || !range.trim()) throw new Error(`${label} must be a non-empty semantic-version comparator range.`);
|
|
249
|
+
return range.split('||').map((part) => part.trim()).filter(Boolean).some((alternative) => alternative.split(/\s+/).every((comparator) => {
|
|
250
|
+
const match = /^(<=|>=|<|>|=)?(v?\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)$/.exec(comparator);
|
|
251
|
+
if (!match) throw new Error(`${label} contains unsupported comparator ${comparator}.`);
|
|
252
|
+
const comparison = compareVersion(version, parseVersion(match[2], label));
|
|
253
|
+
return ({ '>': comparison > 0, '>=': comparison >= 0, '<': comparison < 0, '<=': comparison <= 0, '=': comparison === 0, undefined: comparison === 0 })[match[1]];
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
function scrubbedNodeEnvironment(node) {
|
|
257
|
+
return {
|
|
258
|
+
PATH: `${dirname(node)}:/usr/bin:/bin`,
|
|
259
|
+
HOME: '/',
|
|
260
|
+
LANG: 'C',
|
|
261
|
+
LC_ALL: 'C',
|
|
262
|
+
NODE_OPTIONS: '',
|
|
263
|
+
NODE_PATH: '',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function verifyNodeRuntime(node, nodeRange) {
|
|
267
|
+
if ((regular(node, 'Node executable').mode & 0o111) === 0) throw new Error('Node executable must have an execute permission bit.');
|
|
268
|
+
const result = spawnSync(node, ['--version'], { encoding: 'utf8', timeout: 5_000, env: scrubbedNodeEnvironment(node) });
|
|
269
|
+
if (result.error || result.status !== 0) throw new Error('Node executable did not successfully run --version.');
|
|
270
|
+
const verified = parseVersion(result.stdout.trim(), 'Node --version');
|
|
271
|
+
if (!satisfiesRange(verified, nodeRange, 'Package Node engines')) throw new Error(`Node version ${verified.text} does not satisfy package engines ${nodeRange}.`);
|
|
272
|
+
return verified.text;
|
|
273
|
+
}
|
|
274
|
+
function npmCli(node, suppliedPath) {
|
|
275
|
+
const fallback = join(dirname(node), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
276
|
+
const path = assertAbsolute(suppliedPath ?? fallback, 'NPM CLI path');
|
|
277
|
+
regular(path, 'NPM CLI');
|
|
278
|
+
const result = spawnSync(node, [path, '--version'], { encoding: 'utf8', timeout: 5_000, env: scrubbedNodeEnvironment(node) });
|
|
279
|
+
if (result.error || result.status !== 0) throw new Error('NPM CLI did not successfully run --version with the selected Node executable.');
|
|
280
|
+
return { path, version: parseVersion(result.stdout.trim(), 'NPM CLI version').text };
|
|
281
|
+
}
|
|
282
|
+
function installLockedDependencies(root, node, suppliedNpmPath) {
|
|
283
|
+
const npm = npmCli(node, suppliedNpmPath);
|
|
284
|
+
const cache = assertAbsolute(process.env.npm_config_cache ?? join(process.env.HOME ?? dirname(root), '.npm'), 'NPM cache path');
|
|
285
|
+
const temporary = privateDirectory(join(root, '.npm-tmp'), 'NPM temporary directory');
|
|
286
|
+
let result;
|
|
287
|
+
try {
|
|
288
|
+
result = spawnSync(node, [npm.path, 'ci', '--ignore-scripts', '--offline', '--omit=dev', '--no-audit', '--no-fund'], {
|
|
289
|
+
cwd: root, encoding: 'utf8', timeout: 120_000,
|
|
290
|
+
env: {
|
|
291
|
+
...scrubbedNodeEnvironment(node),
|
|
292
|
+
HOME: temporary, TMPDIR: temporary,
|
|
293
|
+
npm_config_cache: cache, npm_config_ignore_scripts: 'true', npm_config_offline: 'true',
|
|
294
|
+
npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_loglevel: 'error',
|
|
295
|
+
npm_config_userconfig: '/dev/null',
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
if (existing(temporary)) rmSync(temporary, { recursive: true, force: false });
|
|
300
|
+
}
|
|
301
|
+
if (result.error || result.status !== 0) throw new Error('Offline lockfile dependency installation failed; supply a selected Node distribution with its pinned packages available in the local NPM cache.');
|
|
302
|
+
const files = closureFiles(root, ARTIFACT_COPY_ROOTS);
|
|
303
|
+
return {
|
|
304
|
+
files,
|
|
305
|
+
dependency_provenance: {
|
|
306
|
+
type: 'npm_ci_offline_lockfile', lockfile: 'package-lock.json', lockfile_sha256: digest(readFileSync(join(root, 'package-lock.json'))),
|
|
307
|
+
closure_digest: closureDigest(root, files.filter((file) => file === 'node_modules' || file.startsWith('node_modules/'))), npm_cli_version: npm.version,
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function validDependencyProvenance(value) {
|
|
312
|
+
return value && value.type === 'npm_ci_offline_lockfile' && value.lockfile === 'package-lock.json'
|
|
313
|
+
&& /^[a-f0-9]{64}$/.test(value.lockfile_sha256 ?? '') && /^[a-f0-9]{64}$/.test(value.closure_digest ?? '')
|
|
314
|
+
&& typeof value.npm_cli_version === 'string' && /^\d+\.\d+\.\d+$/.test(value.npm_cli_version);
|
|
315
|
+
}
|
|
316
|
+
function runtimeCompatibility(packageManifest, node, supplied = {}, trustedHermesVersion, allowStoredHermesPolicy = false) {
|
|
317
|
+
const nodeRange = packageManifest?.engines?.node;
|
|
318
|
+
const keys = supplied && typeof supplied === 'object' && !Array.isArray(supplied) ? Object.keys(supplied) : null;
|
|
319
|
+
if (!keys || keys.some((key) => key !== 'hermes')) throw new Error('Compatibility accepts only an optional Hermes semantic-version range.');
|
|
320
|
+
const compatibility = { node: { required_range: nodeRange, verified_version: verifyNodeRuntime(node, nodeRange) } };
|
|
321
|
+
if (supplied.hermes !== undefined) {
|
|
322
|
+
const verified = parseVersion(trustedHermesVersion, 'Trusted Hermes host version');
|
|
323
|
+
if (!satisfiesRange(verified, supplied.hermes, 'Hermes compatibility range')) throw new Error(`Hermes version ${verified.text} does not satisfy required range ${supplied.hermes}.`);
|
|
324
|
+
compatibility.hermes = { required_range: supplied.hermes, verified_host_version: verified.text };
|
|
325
|
+
} else if (trustedHermesVersion !== undefined && !allowStoredHermesPolicy) throw new Error('Trusted Hermes host version requires a Hermes compatibility range.');
|
|
326
|
+
return compatibility;
|
|
327
|
+
}
|
|
328
|
+
function canonicalJson(value) {
|
|
329
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value);
|
|
330
|
+
if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value);
|
|
331
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
332
|
+
if (typeof value === 'object') return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
333
|
+
throw new Error('Invalid artifact compatibility metadata.');
|
|
334
|
+
}
|
|
335
|
+
function verifyReceiptCompatibility(receipt, manifest, packageManifest, trustedHermesVersion, trustedNodePath) {
|
|
336
|
+
if (canonicalJson(receipt.compatibility) !== canonicalJson(manifest.compatibility)) throw new Error('Artifact receipt compatibility does not match its build manifest.');
|
|
337
|
+
const nodeRange = packageManifest.engines?.node;
|
|
338
|
+
if (receipt.compatibility.node.required_range !== nodeRange) throw new Error('Artifact receipt Node range does not match the verified package manifest.');
|
|
339
|
+
const receiptNode = assertAbsolute(receipt.node_path, 'Artifact receipt Node executable');
|
|
340
|
+
const trustedNode = assertAbsolute(trustedNodePath === undefined ? receiptNode : trustedNodePath, 'Trusted artifact Node executable');
|
|
341
|
+
if (trustedNodePath !== undefined && receiptNode !== trustedNode) {
|
|
342
|
+
throw new Error('Artifact receipt Node path does not match the trusted Node executable.');
|
|
343
|
+
}
|
|
344
|
+
const actualNodeVersion = verifyNodeRuntime(trustedNode, nodeRange);
|
|
345
|
+
if (actualNodeVersion !== receipt.compatibility.node.verified_version) throw new Error('Artifact Node runtime differs from the verified receipt version.');
|
|
346
|
+
if (receipt.compatibility.hermes !== undefined) {
|
|
347
|
+
const hermes = receipt.compatibility.hermes;
|
|
348
|
+
if (!hermes || typeof hermes.required_range !== 'string' || typeof hermes.verified_host_version !== 'string') throw new Error('Invalid artifact Hermes compatibility receipt.');
|
|
349
|
+
if (trustedHermesVersion === undefined) throw new Error('Trusted Hermes host version is required for this artifact rollback.');
|
|
350
|
+
const actualHermesVersion = parseVersion(trustedHermesVersion, 'Trusted Hermes host version');
|
|
351
|
+
if (!satisfiesRange(actualHermesVersion, hermes.required_range, 'Hermes compatibility range')) throw new Error(`Hermes version ${actualHermesVersion.text} does not satisfy required range ${hermes.required_range}.`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function copyFile(from, to) {
|
|
355
|
+
const contents = readFileSync(from); atomicWrite(to, contents, 'Versioned artifact file');
|
|
356
|
+
}
|
|
357
|
+
function copyClosure(root, destination, files, sourceBlobs) {
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
const committed = sourceBlobs.get(file);
|
|
360
|
+
if (committed) atomicWrite(join(destination, file), committed, 'Versioned artifact file');
|
|
361
|
+
else copyFile(join(root, file), join(destination, file));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function readReceipt(receiptPath, profilePath, trustedHermesVersion, trustedNodePath) {
|
|
365
|
+
const profile = assertAbsolute(profilePath, 'profilePath');
|
|
366
|
+
const receiptFile = validateContainedParent(receiptPath, profile, 'Artifact receipt');
|
|
367
|
+
const receiptStat = regular(receiptFile, 'Artifact receipt');
|
|
368
|
+
assertEffectiveOwner(receiptStat, 'Artifact receipt');
|
|
369
|
+
if (receiptStat.nlink !== 1) throw new Error('Artifact receipt must not be hard linked.');
|
|
370
|
+
let receipt;
|
|
371
|
+
try { receipt = JSON.parse(readFileSync(receiptFile, 'utf8')); } catch (error) { throw new Error(`Invalid artifact receipt: ${error.message}`); }
|
|
372
|
+
if (!receipt || receipt.format !== 2 || !SHA.test(receipt.source_git_sha ?? '') || !/^[a-f0-9]{64}$/.test(receipt.artifact_digest ?? '')
|
|
373
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.source_git_closure_digest ?? '') || !validDependencyProvenance(receipt.dependency_provenance)
|
|
374
|
+
|| receipt.package_name !== PACKAGE_NAME || typeof receipt.package_version !== 'string' || !receipt.package_version
|
|
375
|
+
|| receipt.source_repository !== PACKAGE_REPOSITORY || typeof receipt.path !== 'string'
|
|
376
|
+
|| typeof receipt.entrypoint !== 'string' || typeof receipt.node_path !== 'string' || !receipt.compatibility?.node || typeof receipt.compatibility.node.required_range !== 'string'
|
|
377
|
+
|| typeof receipt.compatibility.node.verified_version !== 'string') throw new Error('Invalid artifact receipt.');
|
|
378
|
+
const artifactPath = assertAbsolute(receipt.path, 'Artifact path');
|
|
379
|
+
assertInside(artifactPath, profile, 'Artifact path');
|
|
380
|
+
validateContainedDirectory(artifactPath, profile, 'Artifact path');
|
|
381
|
+
const manifestPath = join(artifactPath, 'lucy-hermes-build-manifest.json');
|
|
382
|
+
const manifestStat = regular(manifestPath, 'Artifact build manifest');
|
|
383
|
+
assertEffectiveOwner(manifestStat, 'Artifact build manifest');
|
|
384
|
+
if (manifestStat.nlink !== 1) throw new Error('Artifact build manifest must not be hard linked.');
|
|
385
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
386
|
+
if (!manifest || manifest.format !== 1 || !/^[a-f0-9]{64}$/.test(manifest.source_git_closure_digest ?? '') || !validDependencyProvenance(manifest.dependency_provenance)
|
|
387
|
+
|| manifest.artifact_digest !== receipt.artifact_digest || manifest.source_git_sha !== receipt.source_git_sha
|
|
388
|
+
|| manifest.source_git_closure_digest !== receipt.source_git_closure_digest || canonicalJson(manifest.dependency_provenance) !== canonicalJson(receipt.dependency_provenance)
|
|
389
|
+
|| manifest.package_name !== receipt.package_name || manifest.package_version !== receipt.package_version
|
|
390
|
+
|| manifest.source_repository !== receipt.source_repository || manifest.entrypoint !== 'src/mcp/bin.mjs') throw new Error('Artifact receipt does not match its build manifest.');
|
|
391
|
+
const expectedEntrypoint = join(artifactPath, 'src/mcp/bin.mjs');
|
|
392
|
+
if (receipt.entrypoint !== expectedEntrypoint) throw new Error('Artifact receipt entrypoint does not match its artifact path.');
|
|
393
|
+
const files = closureFiles(artifactPath);
|
|
394
|
+
if (closureDigest(artifactPath, files) !== receipt.artifact_digest) throw new Error('Versioned artifact digest mismatch.');
|
|
395
|
+
regular(expectedEntrypoint, 'Versioned artifact entrypoint');
|
|
396
|
+
regular(join(artifactPath, 'package.json'), 'Versioned artifact package manifest');
|
|
397
|
+
let packageManifest;
|
|
398
|
+
try { packageManifest = JSON.parse(readFileSync(join(artifactPath, 'package.json'), 'utf8')); } catch (error) { throw new Error(`Invalid verified artifact package manifest: ${error.message}`); }
|
|
399
|
+
const packageIdentity = packageProvenance(packageManifest);
|
|
400
|
+
if (packageIdentity.package_name !== receipt.package_name || packageIdentity.package_version !== receipt.package_version || packageIdentity.source_repository !== receipt.source_repository) throw new Error('Artifact receipt package metadata does not match its verified package manifest.');
|
|
401
|
+
verifyReceiptCompatibility(receipt, manifest, packageManifest, trustedHermesVersion, trustedNodePath);
|
|
402
|
+
const trustedNode = trustedNodePath === undefined
|
|
403
|
+
? assertAbsolute(receipt.node_path, 'Artifact receipt Node executable')
|
|
404
|
+
: assertAbsolute(trustedNodePath, 'Trusted artifact Node executable');
|
|
405
|
+
return { ...receipt, path: artifactPath, node_path: trustedNode };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function existing(path) { return lstatSync(path, { throwIfNoEntry: false }); }
|
|
409
|
+
function acquirePublicationLock(profile) {
|
|
410
|
+
const state = privateDirectory(join(profile, '.lucy-memory'), 'Artifact state directory');
|
|
411
|
+
return acquireHermesAdvisoryLock(
|
|
412
|
+
state,
|
|
413
|
+
'.installer.lock',
|
|
414
|
+
'Artifact publication lock',
|
|
415
|
+
'Another artifact publication is active for this isolated profile.',
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
function reclaimAbandonedStaging(staging, profile) {
|
|
419
|
+
for (const name of readdirSync(staging).sort()) {
|
|
420
|
+
if (!/^\.build-[a-f0-9]{32}$/.test(name)) continue;
|
|
421
|
+
const candidate = join(staging, name);
|
|
422
|
+
validateContainedDirectory(candidate, profile, 'Abandoned artifact staging build');
|
|
423
|
+
rmSync(candidate, { recursive: true, force: false });
|
|
424
|
+
}
|
|
425
|
+
syncDirectory(staging);
|
|
426
|
+
}
|
|
427
|
+
function discardIncompletePublication(artifactRoot, receiptPath, profile) {
|
|
428
|
+
const artifact = existing(artifactRoot); const receipt = existing(receiptPath);
|
|
429
|
+
if (!artifact && !receipt) return;
|
|
430
|
+
if (artifact) { validateContainedDirectory(artifactRoot, profile, 'Incomplete artifact'); rmSync(artifactRoot, { recursive: true, force: false }); }
|
|
431
|
+
if (receipt) { validateContainedParent(receiptPath, profile, 'Incomplete artifact receipt'); regular(receiptPath, 'Incomplete artifact receipt'); rmSync(receiptPath); }
|
|
432
|
+
syncDirectory(dirname(artifactRoot)); syncDirectory(dirname(receiptPath));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function installHermesLucyArtifact(profilePath, { sourcePath, nodePath, npmPath, version, expectedSourceGitSha, compatibility = {}, trustedHermesVersion } = {}) {
|
|
436
|
+
const profile = privateProfile(profilePath);
|
|
437
|
+
const source = assertAbsolute(sourcePath, 'Artifact source');
|
|
438
|
+
const node = assertAbsolute(nodePath, 'Node executable');
|
|
439
|
+
if (typeof version !== 'string' || !VERSION.test(version)) throw new Error('Artifact version must be a simple version identifier.');
|
|
440
|
+
const sourceStat = regular(source, 'Artifact source');
|
|
441
|
+
assertEffectiveOwner(sourceStat, 'Artifact source');
|
|
442
|
+
if (sourceStat.nlink !== 1) throw new Error('Artifact source must not be hard linked.');
|
|
443
|
+
const root = packageRoot(source);
|
|
444
|
+
validateSafeDirectoryChain(root, 'Artifact source package', { requireLeafOwner: true });
|
|
445
|
+
const identity = provenance(root, expectedSourceGitSha); const sourceFiles = closureFiles(root, SOURCE_COPY_ROOTS);
|
|
446
|
+
const { source_blobs: sourceBlobs, ...sourceProvenance } = verifyCommittedSource(root, sourceFiles, identity.source_git_sha);
|
|
447
|
+
const verifiedPackageManifest = JSON.parse(sourceBlobs.get('package.json').toString('utf8'));
|
|
448
|
+
const packageIdentity = packageProvenance(verifiedPackageManifest);
|
|
449
|
+
verifyNodeRuntime(node, verifiedPackageManifest.engines?.node);
|
|
450
|
+
const artifacts = privateDirectory(join(profile, '.lucy-memory', 'artifacts'), 'Artifact directory');
|
|
451
|
+
const receipts = privateDirectory(join(profile, '.lucy-memory', 'receipts'), 'Artifact receipt directory');
|
|
452
|
+
const staging = privateDirectory(join(profile, '.lucy-memory', 'staging'), 'Artifact staging directory');
|
|
453
|
+
const releaseLock = acquirePublicationLock(profile);
|
|
454
|
+
let stage; let artifactRoot; let receiptPath; let published = false;
|
|
455
|
+
try {
|
|
456
|
+
releaseLock.assertHeld();
|
|
457
|
+
reclaimAbandonedStaging(staging, profile);
|
|
458
|
+
stage = privateDirectory(join(staging, `.build-${randomBytes(16).toString('hex')}`), 'Artifact staging build');
|
|
459
|
+
copyClosure(root, stage, sourceFiles, sourceBlobs);
|
|
460
|
+
const { files, dependency_provenance: dependencyProvenance } = installLockedDependencies(stage, node, npmPath);
|
|
461
|
+
const artifactDigest = closureDigest(stage, files);
|
|
462
|
+
artifactRoot = join(artifacts, `${version}-${artifactDigest.slice(0, 16)}`);
|
|
463
|
+
receiptPath = join(receipts, `${version}-${artifactDigest.slice(0, 16)}.json`);
|
|
464
|
+
const artifactExists = Boolean(existing(artifactRoot)); const receiptExists = Boolean(existing(receiptPath));
|
|
465
|
+
if (artifactExists !== receiptExists) {
|
|
466
|
+
releaseLock.assertHeld();
|
|
467
|
+
discardIncompletePublication(artifactRoot, receiptPath, profile);
|
|
468
|
+
}
|
|
469
|
+
const cachedArtifact = artifactExists && receiptExists;
|
|
470
|
+
if (cachedArtifact && (compatibility?.hermes === undefined || trustedHermesVersion === undefined)) {
|
|
471
|
+
throw new Error('Cached artifact reuse requires an explicit trusted Hermes compatibility policy.');
|
|
472
|
+
}
|
|
473
|
+
const enforcedCompatibility = runtimeCompatibility(verifiedPackageManifest, node, compatibility, trustedHermesVersion);
|
|
474
|
+
const entryPath = join(artifactRoot, 'src/mcp/bin.mjs');
|
|
475
|
+
const buildManifest = { format: 1, ...identity, ...packageIdentity, ...sourceProvenance, dependency_provenance: dependencyProvenance, artifact_digest: artifactDigest, compatibility: enforcedCompatibility, entrypoint: 'src/mcp/bin.mjs' };
|
|
476
|
+
if (cachedArtifact) {
|
|
477
|
+
const receipt = readReceipt(receiptPath, profile, trustedHermesVersion, node);
|
|
478
|
+
if (receipt.artifact_digest !== artifactDigest || receipt.node_path !== node || receipt.source_git_closure_digest !== sourceProvenance.source_git_closure_digest
|
|
479
|
+
|| canonicalJson(receipt.dependency_provenance) !== canonicalJson(dependencyProvenance)) throw new Error('Existing artifact receipt conflicts with requested artifact.');
|
|
480
|
+
if (compatibility?.hermes !== undefined && receipt.compatibility.hermes?.required_range !== compatibility.hermes) throw new Error('Existing artifact receipt conflicts with requested compatibility.');
|
|
481
|
+
releaseLock.assertHeld();
|
|
482
|
+
rmSync(stage, { recursive: true, force: false });
|
|
483
|
+
return { ...receipt, receiptPath };
|
|
484
|
+
}
|
|
485
|
+
if (closureDigest(stage, files) !== artifactDigest
|
|
486
|
+
|| closureDigest(stage, trackedSourceFiles(files)) !== sourceProvenance.source_git_closure_digest
|
|
487
|
+
|| closureDigest(stage, files.filter((file) => file === 'node_modules' || file.startsWith('node_modules/'))) !== dependencyProvenance.closure_digest) {
|
|
488
|
+
throw new Error('Staged artifact bytes do not match the immutable source and lockfile-built dependency closures.');
|
|
489
|
+
}
|
|
490
|
+
atomicWrite(join(stage, 'lucy-hermes-build-manifest.json'), `${JSON.stringify(buildManifest, null, 2)}\n`, 'Artifact build manifest');
|
|
491
|
+
releaseLock.assertHeld();
|
|
492
|
+
renameSync(stage, artifactRoot); published = true; syncDirectory(artifacts);
|
|
493
|
+
const receipt = { ...buildManifest, format: 2, version, path: artifactRoot, entrypoint: entryPath, node_path: node, created_at: new Date().toISOString() };
|
|
494
|
+
releaseLock.assertHeld();
|
|
495
|
+
atomicWrite(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, 'Artifact receipt');
|
|
496
|
+
return { ...receipt, receiptPath };
|
|
497
|
+
} catch (error) {
|
|
498
|
+
// Never perform recovery writes after the lock pathname or descriptor
|
|
499
|
+
// identity has changed. A later verified lease can reclaim the contained
|
|
500
|
+
// staging/incomplete publication safely.
|
|
501
|
+
releaseLock.assertHeld();
|
|
502
|
+
if (published) discardIncompletePublication(artifactRoot, receiptPath, profile);
|
|
503
|
+
else if (stage && existing(stage)) rmSync(stage, { recursive: true, force: false });
|
|
504
|
+
throw error;
|
|
505
|
+
} finally {
|
|
506
|
+
releaseLock();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function verifyArtifactAgainstTrustedSource(receipt, profilePath, sourcePath, expectedSourceGitSha, trustedNodePath) {
|
|
511
|
+
const profile = assertAbsolute(profilePath, 'profilePath');
|
|
512
|
+
const trustedNode = assertAbsolute(trustedNodePath, 'Trusted rollback Node executable');
|
|
513
|
+
const source = assertAbsolute(sourcePath, 'Artifact source'); regular(source, 'Artifact source');
|
|
514
|
+
const root = packageRoot(source); const identity = provenance(root, expectedSourceGitSha);
|
|
515
|
+
if (receipt.source_git_sha !== identity.source_git_sha) throw new Error('Artifact receipt source Git SHA does not match the trusted rollback pin.');
|
|
516
|
+
const files = closureFiles(root, SOURCE_COPY_ROOTS);
|
|
517
|
+
const { source_blobs: trustedBlobs, source_git_closure_digest: trustedDigest } = verifyCommittedSource(root, files, identity.source_git_sha);
|
|
518
|
+
if (trustedDigest !== receipt.source_git_closure_digest) throw new Error('Artifact receipt source closure does not match the trusted rollback source.');
|
|
519
|
+
const trustedPackage = packageProvenance(JSON.parse(trustedBlobs.get('package.json').toString('utf8')));
|
|
520
|
+
if (trustedPackage.package_name !== receipt.package_name || trustedPackage.package_version !== receipt.package_version || trustedPackage.source_repository !== receipt.source_repository) throw new Error('Artifact receipt package metadata does not match the trusted rollback source.');
|
|
521
|
+
for (const [file, contents] of trustedBlobs) {
|
|
522
|
+
const artifactFile = join(receipt.path, file); regular(artifactFile, `Trusted artifact source ${file}`);
|
|
523
|
+
if (!readFileSync(artifactFile).equals(contents)) throw new Error(`Artifact source bytes do not match the trusted rollback source: ${file}.`);
|
|
524
|
+
}
|
|
525
|
+
const verificationRoot = privateDirectory(join(profile, '.lucy-memory', 'rollback-verification'), 'Rollback dependency verification directory');
|
|
526
|
+
const verification = privateDirectory(join(verificationRoot, `.verify-${randomBytes(16).toString('hex')}`), 'Rollback dependency verification build');
|
|
527
|
+
try {
|
|
528
|
+
for (const [file, contents] of trustedBlobs) atomicWrite(join(verification, file), contents, 'Trusted rollback source file');
|
|
529
|
+
const { files: rebuiltFiles, dependency_provenance: rebuilt } = installLockedDependencies(verification, trustedNode);
|
|
530
|
+
const rebuiltDependencies = rebuiltFiles.filter((file) => file === 'node_modules' || file.startsWith('node_modules/'));
|
|
531
|
+
const artifactDependencies = closureFiles(receipt.path).filter((file) => file === 'node_modules' || file.startsWith('node_modules/'));
|
|
532
|
+
if (rebuilt.lockfile_sha256 !== digest(trustedBlobs.get('package-lock.json'))
|
|
533
|
+
|| receipt.dependency_provenance.lockfile_sha256 !== rebuilt.lockfile_sha256
|
|
534
|
+
|| receipt.dependency_provenance.closure_digest !== rebuilt.closure_digest
|
|
535
|
+
|| closureDigest(receipt.path, artifactDependencies) !== rebuilt.closure_digest
|
|
536
|
+
|| closureDigest(verification, rebuiltDependencies) !== rebuilt.closure_digest) {
|
|
537
|
+
throw new Error('Artifact dependency closure does not match the trusted offline lockfile rebuild.');
|
|
538
|
+
}
|
|
539
|
+
} finally {
|
|
540
|
+
if (existing(verification)) rmSync(verification, { recursive: true, force: false });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function verifyHermesLucyArtifact(receiptPath, profilePath, trustedHermesVersion, trustedNodePath) { return readReceipt(receiptPath, assertAbsolute(profilePath, 'profilePath'), trustedHermesVersion, trustedNodePath); }
|
|
545
|
+
export function verifyHermesLucyArtifactAgainstSource(receiptPath, profilePath, { sourcePath, expectedSourceGitSha, trustedHermesVersion, trustedNodePath } = {}) {
|
|
546
|
+
const profile = assertAbsolute(profilePath, 'profilePath');
|
|
547
|
+
if (typeof trustedNodePath !== 'string' || !trustedNodePath) throw new Error('Trusted rollback Node executable is required.');
|
|
548
|
+
const receipt = readReceipt(receiptPath, profile, trustedHermesVersion, trustedNodePath);
|
|
549
|
+
verifyArtifactAgainstTrustedSource(receipt, profile, sourcePath, expectedSourceGitSha, trustedNodePath);
|
|
550
|
+
return receipt;
|
|
551
|
+
}
|