@entrorove/skill 0.1.22 → 0.1.24
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/AGREEMENT.md +6 -6
- package/CHANGELOG.md +10 -0
- package/SKILL.md +12 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +18 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +22 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-cli.mjs +49 -16
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +124 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +97 -0
- package/local-formula-engine.mjs +111 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +107 -0
- package/local-ledger.mjs +2 -7
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +96 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +86 -0
- package/local-mutation-files.mjs +245 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +38 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +153 -3
- package/release-state.json +153 -17
- package/release.mjs +238 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { scopeFail, scopePath, scopeRead, scopeTarget } from './local-scope-files.mjs';
|
|
4
|
+
|
|
5
|
+
async function manifest(root, relative, input) {
|
|
6
|
+
try {
|
|
7
|
+
const read = await scopeRead(root, relative, input);
|
|
8
|
+
return { value: JSON.parse(read.content.toString('utf8')), path: relative, sha256: read.sha256 };
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error.code === 'ENOENT') return null;
|
|
11
|
+
if (error instanceof SyntaxError) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace package manifest is invalid JSON');
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
async function patterns(root, input) {
|
|
16
|
+
const main = await manifest(root, 'package.json', input);
|
|
17
|
+
const metadata = main ? [{ path: main.path, sha256: main.sha256 }] : [];
|
|
18
|
+
let result = main?.value.workspaces ?? [];
|
|
19
|
+
if (!Array.isArray(result)) result = result.packages;
|
|
20
|
+
if (!Array.isArray(result) || result.some(pattern => typeof pattern !== 'string')) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace patterns must be a literal string array');
|
|
21
|
+
try {
|
|
22
|
+
const read = await scopeRead(root, 'pnpm-workspace.yaml', input); const yaml = read.content.toString('utf8'); let active = false;
|
|
23
|
+
metadata.push({ path: 'pnpm-workspace.yaml', sha256: read.sha256 });
|
|
24
|
+
for (const line of yaml.split('\n')) {
|
|
25
|
+
if (/^packages:\s*$/.test(line)) { active = true; continue; }
|
|
26
|
+
if (/^[A-Za-z]/.test(line)) active = false;
|
|
27
|
+
if (!active || !line.trim() || line.trim().startsWith('#')) continue;
|
|
28
|
+
const match = /^\s+-\s+['"]?([A-Za-z0-9_./*-]+)['"]?\s*$/.exec(line);
|
|
29
|
+
if (!match) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace YAML requires supported literal package patterns');
|
|
30
|
+
result.push(match[1]);
|
|
31
|
+
}
|
|
32
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
33
|
+
return { patterns: [...new Set(result)], metadata };
|
|
34
|
+
}
|
|
35
|
+
async function packageDirectories(root, pattern) {
|
|
36
|
+
if (!pattern.includes('*')) { scopePath(pattern); return [pattern]; }
|
|
37
|
+
if (!pattern.endsWith('/*') || pattern.slice(0, -2).includes('*')) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace glob needs a literal parent and a single terminal wildcard');
|
|
38
|
+
const parent = scopePath(pattern.slice(0, -2)); const item = await scopeTarget(root, parent, true);
|
|
39
|
+
if (!item.exists) return [];
|
|
40
|
+
if (!item.stat.isDirectory()) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace parent must be a real directory');
|
|
41
|
+
const result = [];
|
|
42
|
+
for (const name of (await fs.readdir(item.target)).sort()) {
|
|
43
|
+
const directory = `${parent}/${name}`; const target = await scopeTarget(root, directory);
|
|
44
|
+
if (target.stat.isDirectory()) result.push(directory);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
export async function workspaceIndex(root, input = { repositoryRoot: root }) {
|
|
49
|
+
const index = new Map(); let count = 0;
|
|
50
|
+
const workspace = await patterns(root, input); index.metadata = workspace.metadata;
|
|
51
|
+
for (const pattern of workspace.patterns) for (const directory of await packageDirectories(root, pattern)) {
|
|
52
|
+
if (++count > 1000) scopeFail('ENTROROVE_SCOPE_LIMIT', 'workspace metadata exceeds the 1000-package bound');
|
|
53
|
+
const entry = await manifest(root, `${directory}/package.json`, input);
|
|
54
|
+
if (!entry) continue;
|
|
55
|
+
if (typeof entry.value.name !== 'string' || !entry.value.name) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace package requires its actual package name');
|
|
56
|
+
if (index.has(entry.value.name)) scopeFail('ENTROROVE_PACKAGE_INVALID', 'workspace package names are ambiguous');
|
|
57
|
+
index.metadata.push({ path: entry.path, sha256: entry.sha256 });
|
|
58
|
+
index.set(entry.value.name, { directory, ...entry });
|
|
59
|
+
}
|
|
60
|
+
index.metadata.sort((a, b) => a.path.localeCompare(b.path));
|
|
61
|
+
return index;
|
|
62
|
+
}
|
|
63
|
+
function exportedTarget(value) {
|
|
64
|
+
if (typeof value === 'string') return value;
|
|
65
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
66
|
+
const targets = [...new Set(Object.entries(value).filter(([condition]) => condition !== 'types').map(([, entry]) => exportedTarget(entry)).filter(Boolean))];
|
|
67
|
+
if (targets.length > 1) scopeFail('ENTROROVE_IMPORT_AMBIGUOUS', 'conditional workspace export needs an actual unambiguous execution context');
|
|
68
|
+
return targets[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
export function workspaceImport(index, specifier) {
|
|
71
|
+
const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
|
|
72
|
+
const entry = index.get(name); if (!entry) return null;
|
|
73
|
+
const subpath = specifier === name ? '.' : `.${specifier.slice(name.length)}`;
|
|
74
|
+
const exports = entry.value.exports;
|
|
75
|
+
const rootConditions = exports && typeof exports === 'object' && !Array.isArray(exports) && Object.keys(exports).every(key => !key.startsWith('.'));
|
|
76
|
+
let target = subpath === '.' && (typeof exports === 'string' || rootConditions) ? exportedTarget(exports) : exportedTarget(exports?.[subpath]);
|
|
77
|
+
if (!target && subpath === '.' && exports === undefined) target = entry.value.module ?? entry.value.main ?? 'index.js';
|
|
78
|
+
if (!target && subpath !== '.' && entry.value.exports === undefined) target = subpath.slice(2);
|
|
79
|
+
if (typeof target !== 'string') scopeFail('ENTROROVE_IMPORT_UNRESOLVED', 'workspace import has no explicit resolvable export');
|
|
80
|
+
const relative = path.posix.normalize(path.posix.join(entry.directory, target)); scopePath(relative);
|
|
81
|
+
if (!relative.startsWith(`${entry.directory}/`)) scopeFail('ENTROROVE_PATH_UNSAFE', 'workspace export escapes its package boundary');
|
|
82
|
+
return relative;
|
|
83
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
7
|
+
import { WORKFLOW_ACCEPTED_PLAN_SCHEMA, WORKFLOW_LOCAL_LEASE_SCHEMA } from './workflow-local-contracts.mjs';
|
|
8
|
+
import { readTaskHandoffFence } from './local-task-handoff-fence.mjs';
|
|
9
|
+
import { canonicalSwarmPaths, SWARM_PRIORITIES } from './local-swarm-state.mjs';
|
|
10
|
+
import { assertSwarmOriginalBaseline } from './local-swarm-baseline.mjs';
|
|
11
|
+
|
|
12
|
+
export const SWARM_GRANT_SCHEMA = 'entrorove.swarm-delegation-grant/1.0';
|
|
13
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/, SHA = /^[0-9a-f]{64}$/;
|
|
14
|
+
const GRANT_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
15
|
+
const GRANT_FIELDS = ['schemaVersion', 'grantId', 'assignmentId', 'assignmentRevision', 'ownerId', 'originOwnerId',
|
|
16
|
+
'taskId', 'taskDigest', 'chainDigest', 'chainRevision', 'flowId', 'planId', 'planDigest', 'stepId', 'executionPlanSha256',
|
|
17
|
+
'workerId', 'runnerId', 'runnerKeyId', 'repositoryId', 'localRepositoryDigest', 'scopeArtifactDigest', 'scopeRevision',
|
|
18
|
+
'snapshotDigest', 'paths', 'frozenManifest', 'frozenManifestDigest', 'priority', 'leaseId', 'leaseDigest', 'generation', 'spawnDepth', 'spawnAllowed',
|
|
19
|
+
'issuedAt', 'expiresAt', 'scopeExpiresAt'];
|
|
20
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
21
|
+
const validatePlan = ajv.compile(WORKFLOW_ACCEPTED_PLAN_SCHEMA), validateLease = ajv.compile(WORKFLOW_LOCAL_LEASE_SCHEMA);
|
|
22
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
23
|
+
export const unsignedSwarmAuthority = value => Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'authorityReceipt'));
|
|
24
|
+
function exact(value, fields) {
|
|
25
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
26
|
+
|| canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'signed authority fields are invalid');
|
|
27
|
+
}
|
|
28
|
+
function configuredKey(name) {
|
|
29
|
+
try {
|
|
30
|
+
const encoded = process.env[name], bytes = Buffer.from(encoded ?? '', 'base64');
|
|
31
|
+
const key = createPublicKey({ key: bytes, format: 'der', type: 'spki' });
|
|
32
|
+
if (key.asymmetricKeyType !== 'ed25519' || key.export({ format: 'der', type: 'spki' }).toString('base64') !== encoded) throw new Error();
|
|
33
|
+
return { key, keyId: sha256(bytes) };
|
|
34
|
+
} catch { fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'the configured original cloud or runner key is unavailable'); }
|
|
35
|
+
}
|
|
36
|
+
function signed(value) {
|
|
37
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'cloud authority must be a signed object');
|
|
38
|
+
const core = unsignedSwarmAuthority(value), receipt = value.authorityReceipt, configured = configuredKey('ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY');
|
|
39
|
+
exact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
|
|
40
|
+
const bytes = Buffer.from(canonical(core)), signature = Buffer.from(receipt.signature ?? '', 'base64url');
|
|
41
|
+
if (bytes.length > 4 * 1024 * 1024 || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0'
|
|
42
|
+
|| receipt.keyId !== configured.keyId || receipt.payloadDigest !== sha256(bytes) || typeof receipt.signature !== 'string'
|
|
43
|
+
|| signature.length !== 64 || signature.toString('base64url') !== receipt.signature || !verify(null, bytes, configured.key, signature)) {
|
|
44
|
+
fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'the original cloud signature or authority digest is invalid');
|
|
45
|
+
}
|
|
46
|
+
return core;
|
|
47
|
+
}
|
|
48
|
+
function lifetime(start, end, maximum, permitExpired = false) {
|
|
49
|
+
const issued = Date.parse(start), expires = Date.parse(end);
|
|
50
|
+
if (typeof start !== 'string' || typeof end !== 'string' || !Number.isFinite(issued) || !Number.isFinite(expires)
|
|
51
|
+
|| issued > Date.now() || expires <= issued || expires - issued > maximum || (!permitExpired && expires <= Date.now())) {
|
|
52
|
+
fail('ENTROROVE_SWARM_AUTHORITY_EXPIRED', 'cloud authority lifetime is invalid or expired');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function verifyStoredSwarmGrant(value, permitExpired = false) {
|
|
56
|
+
const grant = signed(value); exact(grant, GRANT_FIELDS);
|
|
57
|
+
const ids = ['grantId', 'assignmentId', 'ownerId', 'originOwnerId', 'taskId', 'flowId', 'planId', 'stepId', 'workerId', 'runnerId', 'repositoryId', 'leaseId'];
|
|
58
|
+
const hashes = ['taskDigest', 'chainDigest', 'planDigest', 'executionPlanSha256', 'runnerKeyId', 'scopeArtifactDigest', 'snapshotDigest', 'leaseDigest', 'frozenManifestDigest'];
|
|
59
|
+
if (grant.schemaVersion !== SWARM_GRANT_SCHEMA || ids.some(field => typeof grant[field] !== 'string' || !GRANT_ID.test(grant[field]))
|
|
60
|
+
|| hashes.some(field => !SHA.test(grant[field] ?? '')) || !/^[0-9a-f]{32}$/.test(grant.localRepositoryDigest ?? '')
|
|
61
|
+
|| ['assignmentRevision', 'chainRevision', 'scopeRevision', 'generation'].some(field => !Number.isSafeInteger(grant[field]) || grant[field] < 1)
|
|
62
|
+
|| grant.spawnDepth !== 1 || grant.spawnAllowed !== false || !SWARM_PRIORITIES.includes(grant.priority)
|
|
63
|
+
|| canonical(canonicalSwarmPaths(grant.paths)) !== canonical(grant.paths)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'delegation grant identifiers, immutable scope or recursion policy are invalid');
|
|
64
|
+
if (!Array.isArray(grant.frozenManifest) || !grant.frozenManifest.length || grant.frozenManifest.length > 512) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'frozen original manifest is missing');
|
|
65
|
+
for (const file of grant.frozenManifest) {
|
|
66
|
+
exact(file, ['path', 'sha256']); canonicalSwarmPaths([file.path]);
|
|
67
|
+
if (!SHA.test(file.sha256 ?? '')) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'frozen original manifest hash is invalid');
|
|
68
|
+
}
|
|
69
|
+
const ordered = [...grant.frozenManifest].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
|
|
70
|
+
if (new Set(ordered.map(file => file.path)).size !== ordered.length || canonical(ordered) !== canonical(grant.frozenManifest)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'frozen original manifest must be sorted and unique');
|
|
71
|
+
if (sha256(`validator.file-manifest/1.0\n${ordered.map(file => `${file.path}\0${file.sha256}`).join('\n')}`) !== grant.frozenManifestDigest) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'original frozen manifest digest is invalid');
|
|
72
|
+
lifetime(grant.issuedAt, grant.expiresAt, 90_000, permitExpired);
|
|
73
|
+
const scopeExpiry = Date.parse(grant.scopeExpiresAt);
|
|
74
|
+
if (!Number.isFinite(scopeExpiry) || Date.parse(grant.expiresAt) > scopeExpiry) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'delegation expiry exceeds its actual scope authority');
|
|
75
|
+
return grant;
|
|
76
|
+
}
|
|
77
|
+
export function requireSwarmCloudMaterial(input) {
|
|
78
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || typeof input.stepId !== 'string' || !input.stepId
|
|
79
|
+
|| !input.acceptedPlan || !input.lease || !input.delegationGrant || !input.executionAuthority
|
|
80
|
+
|| typeof input.executionAttemptNonce !== 'string' || !ID.test(input.executionAttemptNonce)) {
|
|
81
|
+
fail('ENTROROVE_SWARM_CLOUD_AUTHORITY_REQUIRED', 'peer operations require the original accepted plan, step, cloud lease, delegation grant and fresh execution authority');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function readSignedSwarmMaterial(input, purpose = 'issue') {
|
|
85
|
+
if (!validatePlan(input.acceptedPlan) || !validateLease(input.lease)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'original accepted plan or cloud lease schema is invalid');
|
|
86
|
+
const plan = signed(input.acceptedPlan), lease = signed(input.lease), grant = verifyStoredSwarmGrant(input.delegationGrant, purpose === 'verify' || purpose === 'report');
|
|
87
|
+
const step = plan.steps.find(value => value.stepId === input.stepId);
|
|
88
|
+
if (!step || input.flowId !== plan.flowId || grant.stepId !== input.stepId || (input.workerId !== undefined && input.workerId !== grant.workerId)
|
|
89
|
+
|| ['planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision'].some(field => lease[field] !== plan[field] || grant[field] !== plan[field])
|
|
90
|
+
|| lease.stepId !== step.stepId || lease.sequence !== step.sequence || grant.generation !== lease.generation || grant.leaseId !== lease.leaseId
|
|
91
|
+
|| lease.planDigest !== sha256(canonical(plan)) || grant.planDigest !== lease.planDigest || grant.leaseDigest !== sha256(canonical(lease))
|
|
92
|
+
|| grant.executionPlanSha256 !== step.executionPlanSha256 || sha256(canonical(step.executionPlan) + '\n') !== step.executionPlanSha256
|
|
93
|
+
|| grant.runnerKeyId !== plan.runnerKeyId || grant.runnerKeyId !== configuredKey('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId) {
|
|
94
|
+
fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'peer grant lost its original plan, owner, task, step, lease or registered runner');
|
|
95
|
+
}
|
|
96
|
+
const manifest = step.executionPlan.files;
|
|
97
|
+
if (canonical(manifest) !== canonical(grant.frozenManifest) || step.executionPlan.artifactSha256 !== grant.frozenManifestDigest || lease.verifiedDependencies.length !== step.dependsOn.length
|
|
98
|
+
|| new Set(lease.verifiedDependencies.map(value => value.stepId)).size !== step.dependsOn.length
|
|
99
|
+
|| lease.verifiedDependencies.some(value => !step.dependsOn.includes(value.stepId))) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'the grant changed the original manifest or verified dependency closure');
|
|
100
|
+
lifetime(plan.acceptedAt, plan.expiresAt, 86_400_000, purpose === 'report'); lifetime(lease.issuedAt, lease.expiresAt, 600_000, purpose === 'report');
|
|
101
|
+
if (Date.parse(grant.expiresAt) > Date.parse(lease.expiresAt)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'delegation exceeds the original business lease');
|
|
102
|
+
return { plan, lease, grant, step };
|
|
103
|
+
}
|
|
104
|
+
export async function readSwarmCloudAuthority(input, purpose = 'issue') {
|
|
105
|
+
requireSwarmCloudMaterial(input);
|
|
106
|
+
const { plan, lease, grant, step } = readSignedSwarmMaterial(input, purpose);
|
|
107
|
+
if (typeof input.repositoryRoot !== 'string' || !path.isAbsolute(input.repositoryRoot)) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'repository root must be absolute');
|
|
108
|
+
const rootStat = await fs.lstat(input.repositoryRoot);
|
|
109
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'repository root must be an actual directory');
|
|
110
|
+
const root = await fs.realpath(input.repositoryRoot), home = await fs.realpath(input.home ?? os.homedir()), repositoryDigest = sha256(root).slice(0, 32);
|
|
111
|
+
if (grant.localRepositoryDigest !== repositoryDigest) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'delegation belongs to another physical repository');
|
|
112
|
+
const execution = signed(input.executionAuthority), fence = await readTaskHandoffFence({ ...input, repositoryRoot: root, home });
|
|
113
|
+
const attempt = fence.executionAttempts.find(value => value.authority.attemptNonce === input.executionAttemptNonce);
|
|
114
|
+
if (!attempt || canonical(attempt.authority) !== canonical(input.executionAuthority) || attempt.executionHome !== home
|
|
115
|
+
|| !['ready', 'used'].includes(attempt.status) || execution.schemaVersion !== 'entrorove.task-execution-epoch-authority/1.0'
|
|
116
|
+
|| execution.status !== 'active' || execution.epoch !== fence.epoch || execution.attemptNonce !== input.executionAttemptNonce
|
|
117
|
+
|| execution.taskId !== grant.taskId || execution.leaseId !== lease.leaseId || execution.generation !== lease.generation
|
|
118
|
+
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId', 'localRepositoryDigest'].some(field => execution[field] !== grant[field])) {
|
|
119
|
+
fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'peer grant has no original consumed execution nonce and epoch');
|
|
120
|
+
}
|
|
121
|
+
if (attempt.status === 'ready' && purpose !== 'report') lifetime(execution.issuedAt, execution.expiresAt, 30_000);
|
|
122
|
+
else if (attempt.status === 'used' && purpose !== 'report') {
|
|
123
|
+
const activity = fence.activities.find(value => value.executionId === attempt.executionId && value.leaseId === lease.leaseId && value.epoch === fence.epoch);
|
|
124
|
+
if (!activity || !Number.isSafeInteger(activity.pid) || activity.pid < 1) fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'used execution authority has no actual active process');
|
|
125
|
+
try { process.kill(activity.pid, 0); } catch { fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'the original execution process is no longer active'); }
|
|
126
|
+
}
|
|
127
|
+
if (Date.parse(grant.expiresAt) <= Date.now() && attempt.status !== 'used') fail('ENTROROVE_SWARM_AUTHORITY_EXPIRED', 'historical delegation cannot admit or report a new unused execution');
|
|
128
|
+
const requestedPaths = canonicalSwarmPaths(input.paths ?? grant.paths), paths = grant.paths;
|
|
129
|
+
if (purpose === 'verify' ? requestedPaths.some(target => !paths.includes(target)) : canonical(requestedPaths) !== canonical(paths)) {
|
|
130
|
+
fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'caller paths cannot replace the exact cloud write-path grant');
|
|
131
|
+
}
|
|
132
|
+
for (const target of paths) {
|
|
133
|
+
let current = root;
|
|
134
|
+
for (const part of target.split('/')) {
|
|
135
|
+
current = path.join(current, part);
|
|
136
|
+
try { if ((await fs.lstat(current)).isSymbolicLink()) fail('ENTROROVE_SWARM_PATH_INVALID', 'a granted path crosses an actual symlink'); }
|
|
137
|
+
catch (error) { if (error.code === 'ENOENT') break; throw error; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const authority = { root, home, repositoryDigest, plan, lease, step, grant, epoch: fence.epoch, paths, requestedPaths };
|
|
141
|
+
if (purpose === 'issue') await assertSwarmOriginalBaseline(authority, input);
|
|
142
|
+
return authority;
|
|
143
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
|
+
|
|
6
|
+
function stale(relative) {
|
|
7
|
+
throw Object.assign(new Error(`the original Aimlock baseline changed at ${relative}; obtain a new legitimate scope and plan instead of replacing the old grant`),
|
|
8
|
+
{ code: 'ENTROROVE_SWARM_BASELINE_STALE' });
|
|
9
|
+
}
|
|
10
|
+
async function originalFile(root, relative) {
|
|
11
|
+
let target = root;
|
|
12
|
+
const parts = relative.split('/');
|
|
13
|
+
for (const [index, part] of parts.entries()) {
|
|
14
|
+
target = path.join(target, part);
|
|
15
|
+
let stat;
|
|
16
|
+
try { stat = await fs.lstat(target); } catch { stale(relative); }
|
|
17
|
+
const final = index === parts.length - 1;
|
|
18
|
+
if (stat.isSymbolicLink() || (final ? !stat.isFile() : !stat.isDirectory())) stale(relative);
|
|
19
|
+
}
|
|
20
|
+
if (await fs.realpath(target) !== target) stale(relative);
|
|
21
|
+
return { target, stat: await fs.lstat(target) };
|
|
22
|
+
}
|
|
23
|
+
export async function assertSwarmOriginalBaseline(authority, input) {
|
|
24
|
+
const reader = { ...input, repositoryRoot: authority.root, home: authority.home, flowId: authority.plan.flowId };
|
|
25
|
+
for (const file of authority.grant.frozenManifest) {
|
|
26
|
+
const before = await originalFile(authority.root, file.path);
|
|
27
|
+
const bytes = await readBudgetedProjectFile(reader, before.target, { sourcePath: file.path });
|
|
28
|
+
const after = await originalFile(authority.root, file.path);
|
|
29
|
+
if (before.stat.dev !== after.stat.dev || before.stat.ino !== after.stat.ino || before.stat.size !== after.stat.size
|
|
30
|
+
|| before.stat.mtimeMs !== after.stat.mtimeMs || sha256(bytes) !== file.sha256) stale(file.path);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { readSwarmCloudAuthority } from './local-swarm-authority.mjs';
|
|
5
|
+
import { observeWorkflowLocalStep } from './workflow-local-runner.mjs';
|
|
6
|
+
|
|
7
|
+
const proofs = new WeakMap();
|
|
8
|
+
function unknown() { throw Object.assign(new Error('the original signed completion cache is missing, changed or unknown'), { code: 'ENTROROVE_SWARM_EXECUTION_UNKNOWN' }); }
|
|
9
|
+
function materialDigest(authority, input) {
|
|
10
|
+
return sha256(canonical({ root: authority.root, home: authority.home, epoch: authority.epoch,
|
|
11
|
+
acceptedPlan: input.acceptedPlan, lease: input.lease, delegationGrant: input.delegationGrant,
|
|
12
|
+
executionAuthority: input.executionAuthority, executionAttemptNonce: input.executionAttemptNonce }));
|
|
13
|
+
}
|
|
14
|
+
function cachePath(authority) {
|
|
15
|
+
return path.join(authority.home, '.entrorove', 'workflow-local', authority.repositoryDigest,
|
|
16
|
+
authority.plan.flowId, authority.step.stepId, 'state.json');
|
|
17
|
+
}
|
|
18
|
+
async function readCache(authority) {
|
|
19
|
+
const file = cachePath(authority);
|
|
20
|
+
let directory = authority.home;
|
|
21
|
+
for (const part of path.relative(authority.home, path.dirname(file)).split(path.sep)) {
|
|
22
|
+
directory = path.join(directory, part);
|
|
23
|
+
const stat = await fs.lstat(directory);
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700
|
|
25
|
+
|| (typeof process.getuid === 'function' && stat.uid !== process.getuid())) unknown();
|
|
26
|
+
}
|
|
27
|
+
const before = await fs.lstat(file);
|
|
28
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > 8 * 1024 * 1024
|
|
29
|
+
|| (before.mode & 0o777) !== 0o600 || (typeof process.getuid === 'function' && before.uid !== process.getuid())) unknown();
|
|
30
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
31
|
+
try {
|
|
32
|
+
const opened = await handle.stat();
|
|
33
|
+
if (opened.dev !== before.dev || opened.ino !== before.ino) unknown();
|
|
34
|
+
const bytes = await handle.readFile(), after = await handle.stat(), current = await fs.lstat(file);
|
|
35
|
+
if (bytes.length > 8 * 1024 * 1024 || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs
|
|
36
|
+
|| current.dev !== opened.dev || current.ino !== opened.ino) unknown();
|
|
37
|
+
return { state: JSON.parse(bytes.toString('utf8')), digest: sha256(bytes), dev: opened.dev, ino: opened.ino };
|
|
38
|
+
} finally { await handle.close(); }
|
|
39
|
+
}
|
|
40
|
+
function sameObservedCache(authority, input, cache, result) {
|
|
41
|
+
const state = cache.state, original = { ...result, cached: false };
|
|
42
|
+
const identity = { repositoryDigest: authority.repositoryDigest, planId: authority.plan.planId,
|
|
43
|
+
planDigest: authority.lease.planDigest, flowId: authority.plan.flowId, ownerId: authority.plan.ownerId,
|
|
44
|
+
stepId: authority.step.stepId, sequence: authority.step.sequence, leaseId: authority.lease.leaseId,
|
|
45
|
+
generation: authority.lease.generation, leaseDigest: sha256(canonical(authority.lease)) };
|
|
46
|
+
if (state?.schemaVersion !== 'entrorove.workflow-local-state/1.0' || state.status !== result.status
|
|
47
|
+
|| Object.entries(identity).some(([key, value]) => state[key] !== value)
|
|
48
|
+
|| canonical(state.executionAuthority) !== canonical(input.executionAuthority)
|
|
49
|
+
|| state.resultDigest !== sha256(canonical(original)) || canonical(state.result) !== canonical(original)) unknown();
|
|
50
|
+
const started = Date.parse(result.process?.startedAt), finished = Date.parse(result.process?.finishedAt);
|
|
51
|
+
if (!['reported', 'failed'].includes(result.status) || !result.receipt || !Number.isInteger(result.process?.exitCode)
|
|
52
|
+
|| !Number.isFinite(started) || !Number.isFinite(finished) || started < Date.parse(authority.grant.issuedAt)
|
|
53
|
+
|| started >= Date.parse(authority.grant.expiresAt) || started < Date.parse(authority.lease.issuedAt)
|
|
54
|
+
|| finished < started || finished > Date.parse(authority.lease.expiresAt)) unknown();
|
|
55
|
+
}
|
|
56
|
+
// Call before the outer handoff/repository locks. Tokens can only be minted by
|
|
57
|
+
// the original observer verifying its real persisted runner-signed result.
|
|
58
|
+
export async function captureSwarmCompletion(input) {
|
|
59
|
+
try {
|
|
60
|
+
const material = structuredClone(input), authority = await readSwarmCloudAuthority(material, 'report');
|
|
61
|
+
const result = await observeWorkflowLocalStep({ repositoryRoot: authority.root, home: authority.home,
|
|
62
|
+
acceptedPlan: material.acceptedPlan, lease: material.lease, stepId: authority.step.stepId,
|
|
63
|
+
approvalPath: null, signerConfigPath: null, executionAttemptNonce: material.executionAttemptNonce });
|
|
64
|
+
if (!result) return null;
|
|
65
|
+
const cache = await readCache(authority); sameObservedCache(authority, material, cache, result);
|
|
66
|
+
const token = Object.freeze({});
|
|
67
|
+
proofs.set(token, { materialDigest: materialDigest(authority, material), ...cache, result: structuredClone(result) });
|
|
68
|
+
return token;
|
|
69
|
+
} catch { return null; }
|
|
70
|
+
}
|
|
71
|
+
// The caller holds the original handoff fence and repository lock. Re-read
|
|
72
|
+
// bytes and immutable bindings without acquiring that fence a second time.
|
|
73
|
+
export async function revalidateSwarmCompletion(token, authority, input) {
|
|
74
|
+
try {
|
|
75
|
+
const proof = proofs.get(token);
|
|
76
|
+
if (!proof || proof.materialDigest !== materialDigest(authority, input)) unknown();
|
|
77
|
+
const cache = await readCache(authority);
|
|
78
|
+
if (cache.digest !== proof.digest || cache.dev !== proof.dev || cache.ino !== proof.ino) unknown();
|
|
79
|
+
sameObservedCache(authority, input, cache, proof.result);
|
|
80
|
+
return structuredClone(proof.result);
|
|
81
|
+
} catch { unknown(); }
|
|
82
|
+
}
|
|
83
|
+
export function swarmOwnershipCompletionInput(record, root) {
|
|
84
|
+
return { repositoryRoot: root, home: record.home, flowId: record.flowId,
|
|
85
|
+
stepId: record.delegationGrant?.stepId, workerId: record.workerId, leaseId: record.leaseId,
|
|
86
|
+
acceptedPlan: record.acceptedPlan, lease: record.lease, delegationGrant: record.delegationGrant,
|
|
87
|
+
executionAuthority: record.executionAuthority,
|
|
88
|
+
executionAttemptNonce: record.executionAttemptNonce ?? record.executionAuthority?.attemptNonce };
|
|
89
|
+
}
|
|
90
|
+
export async function captureSwarmOwnershipCompletions(index, root) {
|
|
91
|
+
const captured = new Map();
|
|
92
|
+
if (!Array.isArray(index?.records) || index.records.length > 1024) return captured;
|
|
93
|
+
const intents = Array.isArray(index.intents) && index.intents.length <= 1024 ? index.intents : [];
|
|
94
|
+
for (const record of [...index.records, ...intents]) {
|
|
95
|
+
if (!record || (Date.parse(record.expiresAt) > Date.now() && !['active', 'completed'].includes(record.status))) continue;
|
|
96
|
+
const key = record.grantDigest ?? record.intentId;
|
|
97
|
+
if (captured.has(key)) continue;
|
|
98
|
+
const token = await captureSwarmCompletion(swarmOwnershipCompletionInput(record, root));
|
|
99
|
+
if (token) captured.set(key, token);
|
|
100
|
+
}
|
|
101
|
+
return captured;
|
|
102
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { WORKFLOW_ACCEPTED_PLAN_SCHEMA, WORKFLOW_LOCAL_LEASE_SCHEMA } from './workflow-local-contracts.mjs';
|
|
2
|
+
import { array, enumeration, integer, nullable, object, paths, sha256, text } from './local-contract-types.mjs';
|
|
3
|
+
|
|
4
|
+
const id = { ...text, maxLength: 200, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$' };
|
|
5
|
+
const rootDigest = { type: 'string', pattern: '^[0-9a-f]{32}$' };
|
|
6
|
+
const priority = enumeration(['background', 'normal', 'high', 'urgent']);
|
|
7
|
+
const exact = properties => object(properties, Object.keys(properties));
|
|
8
|
+
const receipt = exact({ schemaVersion: { const: 'entrorove.workflow-authority-receipt/1.0' },
|
|
9
|
+
keyId: sha256, payloadDigest: sha256, signature: { ...text, pattern: '^[A-Za-z0-9_-]{86}$' } });
|
|
10
|
+
export const SWARM_DELEGATION_GRANT_SCHEMA = exact({
|
|
11
|
+
schemaVersion: { const: 'entrorove.swarm-delegation-grant/1.0' }, grantId: id, assignmentId: id,
|
|
12
|
+
assignmentRevision: integer(1), ownerId: id, originOwnerId: id, taskId: id,
|
|
13
|
+
taskDigest: sha256, chainDigest: sha256, chainRevision: integer(1), flowId: id, planId: id,
|
|
14
|
+
planDigest: sha256, stepId: id, executionPlanSha256: sha256, workerId: id, runnerId: id, runnerKeyId: sha256,
|
|
15
|
+
repositoryId: id, localRepositoryDigest: rootDigest, scopeArtifactDigest: sha256, scopeRevision: integer(1),
|
|
16
|
+
scopeExpiresAt: text, snapshotDigest: sha256, paths,
|
|
17
|
+
frozenManifest: { ...array(exact({ path: paths.items, sha256 }), 1), maxItems: 512 },
|
|
18
|
+
frozenManifestDigest: sha256, priority, leaseId: id, leaseDigest: sha256, generation: integer(1),
|
|
19
|
+
spawnDepth: { const: 1 }, spawnAllowed: { const: false }, issuedAt: text, expiresAt: text, authorityReceipt: receipt,
|
|
20
|
+
});
|
|
21
|
+
const executionAuthority = exact({ schemaVersion: { const: 'entrorove.task-execution-epoch-authority/1.0' },
|
|
22
|
+
authorityId: id, attemptNonce: id, taskId: id, flowId: id, ownerId: id, taskDigest: sha256, chainDigest: sha256,
|
|
23
|
+
leaseId: id, generation: integer(1), epoch: integer(0), status: { const: 'active' }, runnerKeyId: sha256,
|
|
24
|
+
localRepositoryDigest: rootDigest, issuedAt: text, expiresAt: text, authorityReceipt: receipt });
|
|
25
|
+
export const SWARM_LOCAL_AUTHORITY_PROPERTIES = Object.freeze({ acceptedPlan: WORKFLOW_ACCEPTED_PLAN_SCHEMA,
|
|
26
|
+
lease: WORKFLOW_LOCAL_LEASE_SCHEMA, delegationGrant: SWARM_DELEGATION_GRANT_SCHEMA,
|
|
27
|
+
executionAuthority, executionAttemptNonce: id, workerId: id, leaseId: id });
|
|
28
|
+
export const SWARM_LOCAL_AUTHORITY_REQUIRED = Object.freeze(Object.keys(SWARM_LOCAL_AUTHORITY_PROPERTIES));
|
|
29
|
+
const common = { repositoryRoot: text, home: text, flowId: id, stepId: id, paths, ...SWARM_LOCAL_AUTHORITY_PROPERTIES };
|
|
30
|
+
const required = ['repositoryRoot', 'flowId', 'stepId', ...SWARM_LOCAL_AUTHORITY_REQUIRED];
|
|
31
|
+
const coordinate = object({ ...common, workerCount: integer(0, 50), priority }, required);
|
|
32
|
+
const spawn = object({ ...common, priority, parentWorkerId: nullable(text) }, [...required, 'paths']);
|
|
33
|
+
const complete = object({ ...common, status: enumeration(['completed', 'failed', 'blocked']),
|
|
34
|
+
approvalPath: nullable(text), signerConfigPath: nullable(text) }, required);
|
|
35
|
+
const verify = object(common, [...required, 'paths']);
|
|
36
|
+
export const SWARM_LOCAL_OPERATION_SCHEMAS = Object.freeze({
|
|
37
|
+
'peer-coordinate': coordinate, coordinate, 'spawn-bind': spawn,
|
|
38
|
+
'peer-complete': complete, complete, 'lease-verify': verify,
|
|
39
|
+
});
|
|
40
|
+
// Capture the authority before verification; the gate must verify this exact
|
|
41
|
+
// binding before signing it into the existing pass. This never issues a grant.
|
|
42
|
+
export function swarmCoordinationBinding(input) {
|
|
43
|
+
const binding = Object.fromEntries(SWARM_LOCAL_AUTHORITY_REQUIRED.map(field => [field, structuredClone(input[field])]));
|
|
44
|
+
if (binding.workerId === undefined) binding.workerId = binding.delegationGrant?.workerId;
|
|
45
|
+
if (binding.leaseId === undefined) binding.leaseId = binding.lease?.leaseId;
|
|
46
|
+
return binding;
|
|
47
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { mutationPass14 } from './local-mutation-wire.mjs';
|
|
5
|
+
import { mutationOriginalBinding14, mutationSnapshotBinding14 } from './local-mutation-binding.mjs';
|
|
6
|
+
import { readSwarmCloudAuthority } from './local-swarm-authority.mjs';
|
|
7
|
+
import { assertSwarmOriginalBaseline } from './local-swarm-baseline.mjs';
|
|
8
|
+
import { canonicalSwarmPaths, swarmPathsOverlap, validateSwarmState } from './local-swarm-state.mjs';
|
|
9
|
+
import { readSwarmJson, readSwarmOwnership, swarmFlowStorage, swarmRepositoryStorage, swarmStorage, validateSwarmOwnership } from './local-swarm-storage.mjs';
|
|
10
|
+
import { readSwarmWriteIntentHistory } from './local-swarm-priority.mjs';
|
|
11
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
12
|
+
|
|
13
|
+
function fail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
14
|
+
const same = (left, right) => canonical(left) === canonical(right);
|
|
15
|
+
function storedInput(record, root) {
|
|
16
|
+
return { repositoryRoot: root, home: record.home, flowId: record.flowId,
|
|
17
|
+
stepId: record.delegationGrant.stepId, workerId: record.workerId, leaseId: record.leaseId,
|
|
18
|
+
paths: record.paths, acceptedPlan: record.acceptedPlan, lease: record.lease,
|
|
19
|
+
delegationGrant: record.delegationGrant, executionAuthority: record.executionAuthority,
|
|
20
|
+
executionAttemptNonce: record.executionAuthority.attemptNonce };
|
|
21
|
+
}
|
|
22
|
+
function matchCaller(input, stored) {
|
|
23
|
+
for (const field of ['flowId', 'stepId', 'workerId', 'leaseId', 'executionAttemptNonce',
|
|
24
|
+
'acceptedPlan', 'lease', 'delegationGrant', 'executionAuthority']) {
|
|
25
|
+
if (input[field] !== undefined && !same(input[field], stored[field])) {
|
|
26
|
+
fail('ENTROROVE_SWARM_LEASE_INVALID', 'mutation caller differs from the original stored worker authority');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (input.paths !== undefined && canonicalSwarmPaths(input.paths).some(target => !stored.paths.includes(target))) {
|
|
30
|
+
fail('ENTROROVE_SWARM_PATH_CONFLICT', 'mutation caller changed its original signed path set');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function matchPass(pass, authority, record, targets) {
|
|
34
|
+
const { grant, lease } = authority;
|
|
35
|
+
if (['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision', 'stepId',
|
|
36
|
+
'planId', 'planDigest', 'executionPlanSha256', 'runnerId', 'runnerKeyId', 'repositoryId',
|
|
37
|
+
'scopeArtifactDigest', 'leaseId', 'generation'].some(field => pass[field] !== grant[field])
|
|
38
|
+
|| pass.leaseDigest !== sha256(canonical(record.lease))
|
|
39
|
+
|| pass.repositoryDigest !== authority.repositoryDigest || pass.epoch !== authority.epoch
|
|
40
|
+
|| pass.executionAuthorityId !== record.executionAuthority.authorityId
|
|
41
|
+
|| pass.executionAuthorityDigest !== sha256(canonical(record.executionAuthority))
|
|
42
|
+
|| pass.executionAttemptNonce !== record.executionAuthority.attemptNonce
|
|
43
|
+
|| pass.generation !== lease.generation
|
|
44
|
+
|| targets.some(target => !grant.paths.includes(target) || !pass.paths.includes(target))
|
|
45
|
+
|| pass.paths.some(value => !grant.paths.includes(value))) {
|
|
46
|
+
fail('ENTROROVE_SWARM_LEASE_INVALID', 'cloud mutation pass differs from the original signed Swarm plan, grant, lease, nonce or paths');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function activeWorker(state, authority, record) {
|
|
50
|
+
validateSwarmState(state, { flowId: authority.plan.flowId, repositoryDigest: authority.repositoryDigest });
|
|
51
|
+
const worker = state.workers.find(value => value.workerId === record.workerId && value.leaseId === record.leaseId);
|
|
52
|
+
if (!worker || worker.status !== 'active' || worker.grantDigest !== record.grantDigest
|
|
53
|
+
|| !same(worker.delegationGrant, record.delegationGrant) || !same(worker.paths, record.paths)
|
|
54
|
+
|| worker.handoffEpoch !== authority.epoch || worker.expiresAt !== record.expiresAt
|
|
55
|
+
|| !Array.isArray(state.delegationGrants) || !state.delegationGrants.some(value => same(value, record.delegationGrant))) {
|
|
56
|
+
fail('ENTROROVE_SWARM_LEASE_INVALID', 'mutation requires its actual active worker and original reserved cloud grant');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function writeForRecord(input, pass, record, targets, performWrite, batch) {
|
|
60
|
+
const stored = storedInput(record, input.repositoryRoot), authority = await readSwarmCloudAuthority(stored, 'verify');
|
|
61
|
+
if (input.home !== authority.home) fail('ENTROROVE_SWARM_LEASE_INVALID', 'mutation cannot move its original execution home');
|
|
62
|
+
matchCaller(input, stored); matchPass(pass, authority, record, targets);
|
|
63
|
+
const storage = await swarmStorage(authority, 'read');
|
|
64
|
+
return withStateLock(storage.stateFile, async () => {
|
|
65
|
+
const fresh = await readSwarmCloudAuthority(stored, 'verify');
|
|
66
|
+
matchPass(mutationPass14(pass), fresh, record, targets);
|
|
67
|
+
activeWorker(await readSwarmJson(storage.stateFile, null), fresh, record);
|
|
68
|
+
if (batch) {
|
|
69
|
+
await assertSwarmOriginalBaseline(fresh, stored);
|
|
70
|
+
await mutationSnapshotBinding14(input, pass, { allSources: true });
|
|
71
|
+
matchPass(mutationPass14(pass), await readSwarmCloudAuthority(stored, 'verify'), record, targets);
|
|
72
|
+
}
|
|
73
|
+
return performWrite();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function rejectLostOwnership(authority, pass) {
|
|
77
|
+
let storage;
|
|
78
|
+
try { storage = await swarmFlowStorage({ ...authority, plan: { flowId: pass.flowId } }, 'read'); }
|
|
79
|
+
catch (error) { if (error.code === 'ENOENT') return; throw error; }
|
|
80
|
+
const state = await readSwarmJson(storage.stateFile, undefined);
|
|
81
|
+
if (state !== undefined) {
|
|
82
|
+
validateSwarmState(state, { flowId: pass.flowId, repositoryDigest: authority.repositoryDigest });
|
|
83
|
+
fail('ENTROROVE_SWARM_RECONCILE_REQUIRED', 'original peer state remains but its repository ownership controls are missing; reconcile before mutation');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function unreservedWrite(input, pass, performWrite, batch) {
|
|
87
|
+
mutationPass14(pass);
|
|
88
|
+
if (batch) await mutationSnapshotBinding14(input, pass, { allSources: true });
|
|
89
|
+
return performWrite();
|
|
90
|
+
}
|
|
91
|
+
async function guardedOwnership(input, pass, authority, storage, targets, performWrite, batch) {
|
|
92
|
+
const marker = await readSwarmJson(storage.markerFile, null), value = await readSwarmJson(storage.ownershipFile, null);
|
|
93
|
+
if (!marker && !value) {
|
|
94
|
+
await rejectLostOwnership(authority, pass);
|
|
95
|
+
if (input.workerId !== undefined || input.delegationGrant !== undefined) {
|
|
96
|
+
fail('ENTROROVE_SWARM_CLOUD_AUTHORITY_REQUIRED', 'the original Swarm worker must coordinate and reserve before mutation');
|
|
97
|
+
}
|
|
98
|
+
return unreservedWrite(input, pass, performWrite, batch);
|
|
99
|
+
}
|
|
100
|
+
const original = await readSwarmOwnership(storage, authority, 'read');
|
|
101
|
+
const ownership = await validateSwarmOwnership(original, authority);
|
|
102
|
+
const { index } = await readSwarmWriteIntentHistory(ownership, authority);
|
|
103
|
+
const matching = ownership.records.filter(record => record.flowId === pass.flowId && record.leaseId === pass.leaseId);
|
|
104
|
+
if (matching.length > 1) fail('ENTROROVE_SWARM_STATE_INVALID', 'mutation lease has multiple original ownership records');
|
|
105
|
+
const record = matching[0];
|
|
106
|
+
if (ownership.records.some(value => value !== record
|
|
107
|
+
&& value.paths.some(scope => targets.some(target => swarmPathsOverlap(scope, target))))) {
|
|
108
|
+
fail('ENTROROVE_SWARM_PATH_CONFLICT', 'another actual signed worker retains this physical write path');
|
|
109
|
+
}
|
|
110
|
+
if (record) return writeForRecord(input, pass, record, targets, performWrite, batch);
|
|
111
|
+
if (index.intents.some(intent => intent.flowId === pass.flowId && intent.stepId === pass.stepId)
|
|
112
|
+
|| input.workerId !== undefined || input.delegationGrant !== undefined) {
|
|
113
|
+
fail('ENTROROVE_SWARM_LEASE_INVALID', 'queued, completed or unreserved Swarm work cannot bypass its actual worker ownership');
|
|
114
|
+
}
|
|
115
|
+
return unreservedWrite(input, pass, performWrite, batch);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The native mutation caller already holds its original handoff write fence.
|
|
119
|
+
// Keep repository -> flow locks through the physical write; never reacquire the
|
|
120
|
+
// handoff fence or invoke a completion observer from inside these locks.
|
|
121
|
+
async function withMutationPaths(rawInput, rawPass, performWrite, batch) {
|
|
122
|
+
if (typeof performWrite !== 'function') fail('ENTROROVE_SWARM_WRITE_INVALID', 'the original physical write callback is required');
|
|
123
|
+
let input, pass;
|
|
124
|
+
try { input = structuredClone(rawInput); pass = structuredClone(rawPass); }
|
|
125
|
+
catch { fail('ENTROROVE_SWARM_AUTHORITY_INVALID', 'mutation authority must be finite JSON data'); }
|
|
126
|
+
mutationPass14(pass);
|
|
127
|
+
const targets = canonicalSwarmPaths(batch ? pass.paths : [input.targetPath]);
|
|
128
|
+
if (targets.some(target => !pass.paths.includes(target))) fail('ENTROROVE_SWARM_PATH_CONFLICT', 'mutation target is outside its original signed pass');
|
|
129
|
+
const root = await fs.realpath(input.repositoryRoot), home = await fs.realpath(input.home ?? os.homedir());
|
|
130
|
+
input = { ...input, repositoryRoot: root, home };
|
|
131
|
+
const binding = await mutationOriginalBinding14(input, pass);
|
|
132
|
+
const authority = { root, home, repositoryDigest: sha256(root).slice(0, 32) };
|
|
133
|
+
if (binding.root !== root || binding.home !== home) fail('ENTROROVE_SWARM_LEASE_INVALID', 'mutation changed its actual registered repository or home');
|
|
134
|
+
const storage = await swarmRepositoryStorage(authority);
|
|
135
|
+
return withStateLock(storage.ownershipFile, () => guardedOwnership(input, pass, authority, storage, targets, performWrite, batch));
|
|
136
|
+
}
|
|
137
|
+
export async function withSwarmMutationLease(input, originalPass, physicalWriteCallback) {
|
|
138
|
+
return withMutationPaths(input, originalPass, physicalWriteCallback, false);
|
|
139
|
+
}
|
|
140
|
+
export async function withSwarmMutationBatchLease(input, originalPass, physicalBatchCallback) {
|
|
141
|
+
return withMutationPaths(input, originalPass, physicalBatchCallback, true);
|
|
142
|
+
}
|