@trylucy/agent-memory 0.0.0 → 0.1.3
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 +200 -0
- package/contracts/hashes.json +23 -0
- package/package.json +83 -5
- package/schemas/canonical-recall/response.schema.json +56 -0
- package/schemas/v1/common.schema.json +191 -0
- package/schemas/v1/contract-document.schema.json +278 -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 +106 -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,174 @@
|
|
|
1
|
+
import { compilePublicSchemas, isExpectedVersionSuccessor, SCHEMA_BASE } from '../../scripts/lib/am01.mjs';
|
|
2
|
+
import {
|
|
3
|
+
InvalidKeyError, InvalidKindError, InvalidScopeError, MalformedResponseError,
|
|
4
|
+
ProposalContractIncompatibilityError, ValidationFailureError,
|
|
5
|
+
} from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
export const MEMORY_KINDS = Object.freeze(['fact', 'preference', 'decision', 'rule', 'context']);
|
|
8
|
+
export const MEMORY_LIFECYCLES = Object.freeze(['active', 'superseded', 'revoked', 'forgotten']);
|
|
9
|
+
export const PROPOSAL_LIFECYCLES = Object.freeze(['pending', 'approved', 'rejected', 'expired', 'superseded']);
|
|
10
|
+
export const SCOPE_KINDS = Object.freeze(['user', 'workspace', 'project']);
|
|
11
|
+
const registry = await compilePublicSchemas();
|
|
12
|
+
const schema = (name) => registry.ajv.getSchema(`${SCHEMA_BASE}${name}.schema.json`);
|
|
13
|
+
const KEY = /^[a-z](?:[a-z0-9]|[._-](?=[a-z0-9])){0,127}$/;
|
|
14
|
+
const PROJECT = /^project_[A-Za-z0-9_-]+$/;
|
|
15
|
+
const MEMORY = /^mem_[A-Za-z0-9_-]+$/;
|
|
16
|
+
const PROPOSAL = /^proposal_[A-Za-z0-9_-]+$/;
|
|
17
|
+
const IDEMPOTENCY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const AUTHORITY = new Set(['tenant', 'tenantid', 'tenantauthority', 'account', 'accountid', 'accountauthority']);
|
|
19
|
+
|
|
20
|
+
function plain(value) {
|
|
21
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
22
|
+
const prototype = Object.getPrototypeOf(value);
|
|
23
|
+
return prototype === Object.prototype || prototype === null;
|
|
24
|
+
}
|
|
25
|
+
function object(value, label) {
|
|
26
|
+
if (!plain(value)) throw new ValidationFailureError(`${label} must be a plain object.`);
|
|
27
|
+
}
|
|
28
|
+
function allowed(value, keys, label) {
|
|
29
|
+
const extra = Object.keys(value).filter((key) => !keys.has(key));
|
|
30
|
+
if (extra.length) throw new ValidationFailureError(`${label} contains unsupported fields: ${extra.sort().join(', ')}.`);
|
|
31
|
+
}
|
|
32
|
+
function noAuthority(value, path = 'request', seen = new Set()) {
|
|
33
|
+
if (value === null || typeof value !== 'object') return;
|
|
34
|
+
if (seen.has(value)) throw new ValidationFailureError(`${path} must not contain circular values.`);
|
|
35
|
+
seen.add(value);
|
|
36
|
+
for (const [key, child] of Object.entries(value)) {
|
|
37
|
+
if (AUTHORITY.has(key.replaceAll('_', '').replaceAll('-', '').toLowerCase())) {
|
|
38
|
+
throw new ValidationFailureError(`${path}.${key} cannot supply tenant or account authority.`);
|
|
39
|
+
}
|
|
40
|
+
noAuthority(child, `${path}.${key}`, seen);
|
|
41
|
+
}
|
|
42
|
+
seen.delete(value);
|
|
43
|
+
}
|
|
44
|
+
function bounded(value, label, { min = 1, max, bytes } = {}) {
|
|
45
|
+
if (typeof value !== 'string' || [...value].length < min || (max !== undefined && [...value].length > max)) {
|
|
46
|
+
throw new ValidationFailureError(`${label} must contain ${min} to ${max ?? 'unlimited'} Unicode scalars.`);
|
|
47
|
+
}
|
|
48
|
+
if (bytes !== undefined && Buffer.byteLength(value) > bytes) throw new ValidationFailureError(`${label} exceeds ${bytes} UTF-8 bytes.`);
|
|
49
|
+
}
|
|
50
|
+
function unique(value, label, max, validate) {
|
|
51
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > max) throw new ValidationFailureError(`${label} must contain 1 to ${max} items.`);
|
|
52
|
+
value.forEach(validate);
|
|
53
|
+
if (new Set(value).size !== value.length) throw new ValidationFailureError(`${label} must contain unique items.`);
|
|
54
|
+
}
|
|
55
|
+
function matchesScope(left, right) { return left?.kind === right?.kind && left?.id === right?.id; }
|
|
56
|
+
function malformedUnlessValid(name, value, label, ErrorType = MalformedResponseError) {
|
|
57
|
+
const validate = schema(name);
|
|
58
|
+
if (!validate(value)) {
|
|
59
|
+
const details = validate.errors?.map(({ instancePath, keyword, schemaPath }) => ({ instancePath, keyword, schemaPath }));
|
|
60
|
+
throw new ErrorType(`${label} violates the frozen AM-01 schema.`, { details });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function validateScope(scope) {
|
|
65
|
+
if (!plain(scope) || !SCOPE_KINDS.includes(scope.kind)) throw new InvalidScopeError('scope.kind must be user, workspace, or project.');
|
|
66
|
+
const keys = scope.kind === 'project' ? new Set(['kind', 'id']) : new Set(['kind']);
|
|
67
|
+
if (Object.keys(scope).length !== keys.size || Object.keys(scope).some((key) => !keys.has(key))) throw new InvalidScopeError(`${scope.kind} scope contains invalid fields.`);
|
|
68
|
+
if (scope.kind === 'project' && (typeof scope.id !== 'string' || scope.id.length > 128 || !PROJECT.test(scope.id))) throw new InvalidScopeError('project scope requires a valid project_ id.');
|
|
69
|
+
return scope;
|
|
70
|
+
}
|
|
71
|
+
export function validateKey(key) {
|
|
72
|
+
if (typeof key !== 'string' || !KEY.test(key)) throw new InvalidKeyError('key must use deterministic lowercase dot, underscore, or hyphen syntax.');
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
export function validateKind(kind) {
|
|
76
|
+
if (!MEMORY_KINDS.includes(kind)) throw new InvalidKindError(`kind must be one of: ${MEMORY_KINDS.join(', ')}.`);
|
|
77
|
+
return kind;
|
|
78
|
+
}
|
|
79
|
+
export function validateRecallRequest(request) {
|
|
80
|
+
object(request, 'recall request'); noAuthority(request);
|
|
81
|
+
allowed(request, new Set(['query', 'scope', 'keys', 'kinds', 'limit']), 'recall request');
|
|
82
|
+
bounded(request.query, 'query', { max: 1024 }); validateScope(request.scope);
|
|
83
|
+
if (request.keys !== undefined) unique(request.keys, 'keys', 20, validateKey);
|
|
84
|
+
if (request.kinds !== undefined) unique(request.kinds, 'kinds', MEMORY_KINDS.length, validateKind);
|
|
85
|
+
if (request.limit !== undefined && (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 5)) throw new ValidationFailureError('limit must be an integer between 1 and 5.');
|
|
86
|
+
return request;
|
|
87
|
+
}
|
|
88
|
+
function commonProposal(request, keys) {
|
|
89
|
+
noAuthority(request); allowed(request, keys, 'proposal request'); validateKey(request.key); validateScope(request.scope);
|
|
90
|
+
bounded(request.content, 'content', { max: 2000, bytes: 8192 });
|
|
91
|
+
bounded(request.justification, 'justification', { max: 500, bytes: 2048 });
|
|
92
|
+
if (typeof request.idempotency_key !== 'string' || !IDEMPOTENCY.test(request.idempotency_key)) throw new ValidationFailureError('idempotency_key must be a UUID.');
|
|
93
|
+
if (request.source_ref !== undefined) bounded(request.source_ref, 'source_ref', { max: 256 });
|
|
94
|
+
}
|
|
95
|
+
export function validateProposalRequest(request) {
|
|
96
|
+
object(request, 'proposal request');
|
|
97
|
+
if (request.operation === 'create') {
|
|
98
|
+
commonProposal(request, new Set(['operation', 'key', 'kind', 'scope', 'content', 'justification', 'idempotency_key', 'source_ref'])); validateKind(request.kind);
|
|
99
|
+
} else if (request.operation === 'replace') {
|
|
100
|
+
commonProposal(request, new Set(['operation', 'memory_id', 'expected_version', 'key', 'scope', 'content', 'justification', 'idempotency_key', 'source_ref']));
|
|
101
|
+
if (typeof request.memory_id !== 'string' || !MEMORY.test(request.memory_id)) throw new ValidationFailureError('replacement requires a valid memory_id.');
|
|
102
|
+
if (!Number.isSafeInteger(request.expected_version) || request.expected_version < 1 || request.expected_version > 9007199254740990) throw new ValidationFailureError('replacement requires a valid expected_version.');
|
|
103
|
+
} else throw new ValidationFailureError('operation must be create or replace.');
|
|
104
|
+
return request;
|
|
105
|
+
}
|
|
106
|
+
export function validateProposalStatusRequest(request) {
|
|
107
|
+
object(request, 'proposal status request'); noAuthority(request); allowed(request, new Set(['proposal_id']), 'proposal status request');
|
|
108
|
+
if (typeof request.proposal_id !== 'string' || !PROPOSAL.test(request.proposal_id)) throw new ValidationFailureError('proposal_id must be valid.');
|
|
109
|
+
return request;
|
|
110
|
+
}
|
|
111
|
+
function frozenRecord(entries) {
|
|
112
|
+
const value = Object.create(null);
|
|
113
|
+
for (const [key, child] of entries) value[key] = child;
|
|
114
|
+
return Object.freeze(value);
|
|
115
|
+
}
|
|
116
|
+
function frozenList(value) {
|
|
117
|
+
const copy = [];
|
|
118
|
+
for (let index = 0; index < value.length; index += 1) copy.push(value[index]);
|
|
119
|
+
return Object.freeze(copy);
|
|
120
|
+
}
|
|
121
|
+
function scopeDto(scope) {
|
|
122
|
+
return frozenRecord(scope.kind === 'project' ? [['kind', scope.kind], ['id', scope.id]] : [['kind', scope.kind]]);
|
|
123
|
+
}
|
|
124
|
+
export function toRecallRequestDto(request) {
|
|
125
|
+
validateRecallRequest(request);
|
|
126
|
+
const entries = [['query', request.query], ['scope', scopeDto(request.scope)]];
|
|
127
|
+
if (Object.hasOwn(request, 'keys')) entries.push(['keys', frozenList(request.keys)]);
|
|
128
|
+
if (Object.hasOwn(request, 'kinds')) entries.push(['kinds', frozenList(request.kinds)]);
|
|
129
|
+
if (Object.hasOwn(request, 'limit')) entries.push(['limit', request.limit]);
|
|
130
|
+
return frozenRecord(entries);
|
|
131
|
+
}
|
|
132
|
+
export function toProposalRequestDto(request) {
|
|
133
|
+
validateProposalRequest(request);
|
|
134
|
+
const entries = request.operation === 'create'
|
|
135
|
+
? [['operation', 'create'], ['key', request.key], ['kind', request.kind], ['scope', scopeDto(request.scope)]]
|
|
136
|
+
: [['operation', 'replace'], ['memory_id', request.memory_id], ['expected_version', request.expected_version], ['key', request.key], ['scope', scopeDto(request.scope)]];
|
|
137
|
+
entries.push(['content', request.content], ['justification', request.justification], ['idempotency_key', request.idempotency_key]);
|
|
138
|
+
if (Object.hasOwn(request, 'source_ref')) entries.push(['source_ref', request.source_ref]);
|
|
139
|
+
return frozenRecord(entries);
|
|
140
|
+
}
|
|
141
|
+
export function toProposalStatusRequestDto(request) {
|
|
142
|
+
validateProposalStatusRequest(request);
|
|
143
|
+
return frozenRecord([['proposal_id', request.proposal_id]]);
|
|
144
|
+
}
|
|
145
|
+
export function validateRecallResponse(value, request) {
|
|
146
|
+
malformedUnlessValid('recall-response', value, 'recall response');
|
|
147
|
+
if (request && !matchesScope(value.scope, request.scope)) throw new MalformedResponseError('Recall response scope does not match the request.');
|
|
148
|
+
for (const memory of value.results) {
|
|
149
|
+
const receipt = memory.receipt;
|
|
150
|
+
if (!matchesScope(value.scope, memory.scope) || memory.id !== receipt.memory_id || memory.version !== receipt.memory_version
|
|
151
|
+
|| memory.key !== receipt.key || memory.kind !== receipt.kind || !matchesScope(memory.scope, receipt.scope) || memory.status !== receipt.lifecycle) {
|
|
152
|
+
throw new MalformedResponseError('recall response violates scope or receipt integrity.');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
export function validateProposalResponse(value, request) {
|
|
158
|
+
malformedUnlessValid('proposal-status', value, 'proposal response', ProposalContractIncompatibilityError);
|
|
159
|
+
if (value.status === 'approved' && (value.operation === 'create'
|
|
160
|
+
? value.result.memory_version !== 1
|
|
161
|
+
: value.result.memory_id !== value.requested.memory_id || !isExpectedVersionSuccessor(value.requested.expected_version, value.result.memory_version))) {
|
|
162
|
+
throw new MalformedResponseError('proposal response violates approved version semantics.');
|
|
163
|
+
}
|
|
164
|
+
if (request?.proposal_id && value.id !== request.proposal_id) throw new MalformedResponseError('Proposal response id does not match the request.');
|
|
165
|
+
if (request?.operation) {
|
|
166
|
+
const requested = value.requested;
|
|
167
|
+
const mismatch = value.operation !== request.operation || requested.key !== request.key || !matchesScope(requested.scope, request.scope)
|
|
168
|
+
|| (request.operation === 'create' ? requested.kind !== request.kind
|
|
169
|
+
: requested.memory_id !== request.memory_id || requested.expected_version !== request.expected_version);
|
|
170
|
+
if (mismatch) throw new MalformedResponseError('Proposal response does not match the submitted proposal.');
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
export function validateErrorResponse(value) { malformedUnlessValid('error', value, 'error response'); return value; }
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
constants,
|
|
4
|
+
fchmodSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
openSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { assertEffectiveOwner, assertTrustedOwner, validateSafeDirectoryChain } from './hermes-filesystem.mjs';
|
|
15
|
+
|
|
16
|
+
const PRIVATE_FILE_MODE = 0o600;
|
|
17
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
18
|
+
const READY_TIMEOUT_MILLISECONDS = 2_000;
|
|
19
|
+
const TRUSTED_FLOCK_PATH = '/usr/bin/flock';
|
|
20
|
+
|
|
21
|
+
function trustedFlockPath() {
|
|
22
|
+
let stat;
|
|
23
|
+
try { stat = lstatSync(TRUSTED_FLOCK_PATH); } catch { return null; }
|
|
24
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o111) === 0 || (stat.mode & 0o022) !== 0) return null;
|
|
25
|
+
try { assertTrustedOwner(stat, 'util-linux flock executable'); } catch { return null; }
|
|
26
|
+
try { validateSafeDirectoryChain('/usr/bin', 'util-linux flock executable parent'); } catch { return null; }
|
|
27
|
+
return TRUSTED_FLOCK_PATH;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function flockFd(flockPath, fd) {
|
|
31
|
+
return spawnSync(flockPath, ['--exclusive', '--nonblock', '3'], {
|
|
32
|
+
stdio: ['ignore', 'ignore', 'ignore', fd],
|
|
33
|
+
timeout: READY_TIMEOUT_MILLISECONDS,
|
|
34
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function openPrivateLock(path, label, { exclusiveCreate = false } = {}) {
|
|
39
|
+
const flags = constants.O_RDWR | constants.O_CREAT | NOFOLLOW | (exclusiveCreate ? constants.O_EXCL : 0);
|
|
40
|
+
const fd = openSync(path, flags, PRIVATE_FILE_MODE);
|
|
41
|
+
try {
|
|
42
|
+
const stat = fstatSync(fd);
|
|
43
|
+
if (!stat.isFile()) throw new Error(`${label} must be a regular file.`);
|
|
44
|
+
assertEffectiveOwner(stat, label);
|
|
45
|
+
if (stat.nlink !== 1) throw new Error(`${label} must not be hard linked.`);
|
|
46
|
+
fchmodSync(fd, PRIVATE_FILE_MODE);
|
|
47
|
+
return fd;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Prove the fixed util-linux executable supports locking an already-open file
|
|
56
|
+
* descriptor. The parent keeps the same open-file description after the
|
|
57
|
+
* one-shot flock process exits, so the kernel lease has no helper lifecycle.
|
|
58
|
+
*/
|
|
59
|
+
function flockAvailable() {
|
|
60
|
+
const flockPath = trustedFlockPath();
|
|
61
|
+
if (!flockPath) return false;
|
|
62
|
+
const version = spawnSync(flockPath, ['--version'], {
|
|
63
|
+
encoding: 'utf8', timeout: READY_TIMEOUT_MILLISECONDS,
|
|
64
|
+
env: { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C' },
|
|
65
|
+
});
|
|
66
|
+
const identity = `${version.stdout ?? ''}\n${version.stderr ?? ''}`;
|
|
67
|
+
if (version.error || version.status !== 0 || !/^flock from util-linux\b/m.test(identity)) return false;
|
|
68
|
+
|
|
69
|
+
let probeRoot; let ownerFd; let contenderFd;
|
|
70
|
+
try {
|
|
71
|
+
probeRoot = mkdtempSync(join(tmpdir(), 'lucy-hermes-flock-probe-'));
|
|
72
|
+
validateSafeDirectoryChain(probeRoot, 'Hermes flock probe', { requireLeafOwner: true });
|
|
73
|
+
const probeLock = join(probeRoot, 'probe.lock');
|
|
74
|
+
ownerFd = openPrivateLock(probeLock, 'Hermes flock probe lock', { exclusiveCreate: true });
|
|
75
|
+
const acquired = flockFd(flockPath, ownerFd);
|
|
76
|
+
if (acquired.error || acquired.status !== 0) return false;
|
|
77
|
+
|
|
78
|
+
contenderFd = openPrivateLock(probeLock, 'Hermes flock probe lock');
|
|
79
|
+
const blocked = flockFd(flockPath, contenderFd);
|
|
80
|
+
if (blocked.error || blocked.status === 0) return false;
|
|
81
|
+
|
|
82
|
+
closeSync(ownerFd); ownerFd = undefined;
|
|
83
|
+
const released = flockFd(flockPath, contenderFd);
|
|
84
|
+
return !released.error && released.status === 0;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
} finally {
|
|
88
|
+
if (ownerFd !== undefined) closeSync(ownerFd);
|
|
89
|
+
if (contenderFd !== undefined) closeSync(contenderFd);
|
|
90
|
+
if (probeRoot) {
|
|
91
|
+
try { rmSync(probeRoot, { recursive: true, force: true }); } catch { /* private probe cleanup is best effort */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Fail before any caller creates installer-owned profile/artifact state. */
|
|
97
|
+
export function assertHermesAdvisoryLockSupport(label) {
|
|
98
|
+
if (process.platform !== 'linux') throw new Error(`${label} requires Linux advisory locking support.`);
|
|
99
|
+
if (!flockAvailable()) throw new Error(`${label} requires the trusted util-linux flock executable at ${TRUSTED_FLOCK_PATH}.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Acquire a kernel advisory lease on the exact O_NOFOLLOW descriptor verified
|
|
104
|
+
* by this process. util-linux applies flock(2) to the inherited descriptor and
|
|
105
|
+
* exits; the parent retains the locked open-file description until release.
|
|
106
|
+
*/
|
|
107
|
+
export function acquireHermesAdvisoryLock(state, name, label, activeMessage) {
|
|
108
|
+
assertHermesAdvisoryLockSupport(label);
|
|
109
|
+
validateSafeDirectoryChain(state, `${label} state directory`, { requireLeafOwner: true, requirePrivateLeaf: true });
|
|
110
|
+
const lockPath = join(state, name);
|
|
111
|
+
const fd = openPrivateLock(lockPath, label);
|
|
112
|
+
const flockPath = trustedFlockPath();
|
|
113
|
+
const acquired = flockFd(flockPath, fd);
|
|
114
|
+
if (acquired.error || acquired.status !== 0) {
|
|
115
|
+
closeSync(fd);
|
|
116
|
+
throw new Error(activeMessage);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const locked = fstatSync(fd);
|
|
120
|
+
let released = false;
|
|
121
|
+
const assertHeld = () => {
|
|
122
|
+
if (released) throw new Error(`${label} advisory lease is no longer held.`);
|
|
123
|
+
const current = fstatSync(fd);
|
|
124
|
+
if (!current.isFile() || current.dev !== locked.dev || current.ino !== locked.ino || current.nlink !== 1) {
|
|
125
|
+
throw new Error(`${label} advisory lease identity changed.`);
|
|
126
|
+
}
|
|
127
|
+
const pathname = lstatSync(lockPath, { throwIfNoEntry: false });
|
|
128
|
+
if (!pathname || pathname.isSymbolicLink() || !pathname.isFile()
|
|
129
|
+
|| pathname.dev !== locked.dev || pathname.ino !== locked.ino) {
|
|
130
|
+
throw new Error(`${label} advisory lock path changed while held.`);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const release = () => {
|
|
134
|
+
if (released) return;
|
|
135
|
+
released = true;
|
|
136
|
+
closeSync(fd);
|
|
137
|
+
};
|
|
138
|
+
Object.defineProperty(release, 'assertHeld', { value: assertHeld, enumerable: false });
|
|
139
|
+
return release;
|
|
140
|
+
}
|