@entrorove/skill 0.1.62 → 0.1.67
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/CAPABILITY-MAP.md +30 -2
- package/CHANGELOG.md +25 -0
- package/DELIVERY-BOUNDARIES.md +2 -0
- package/DESIGN-GATES.md +160 -0
- package/IDE-EXECUTION.md +32 -3
- package/REUSE-DISCIPLINE.md +16 -2
- package/SKILL.md +9 -3
- package/blueprint-criterion-runner.mjs +1 -1
- package/cli-input.mjs +22 -0
- package/cli.mjs +13 -12
- package/cloud-task-client.mjs +10 -2
- package/cloud-task-images.mjs +39 -0
- package/design-browser-checks.mjs +86 -0
- package/design-browser-identity.mjs +13 -0
- package/design-browser-state.mjs +41 -0
- package/design-browser.mjs +181 -0
- package/design-context.mjs +34 -0
- package/design-contracts.d.mts +4 -0
- package/design-contracts.mjs +77 -0
- package/design-declaration.mjs +17 -0
- package/design-frameworks.mjs +4 -0
- package/design-gate.mjs +19 -0
- package/design-lint.mjs +115 -0
- package/design-local-contracts.mjs +15 -0
- package/design-operations.mjs +81 -0
- package/design-policy.d.mts +5 -0
- package/design-policy.mjs +137 -0
- package/design-release.mjs +25 -0
- package/design-research.mjs +99 -0
- package/design-runner.mjs +31 -0
- package/design-store.mjs +116 -0
- package/design-ui.mjs +24 -0
- package/ide-exit.mjs +49 -9
- package/ide-hook.mjs +2 -2
- package/install-identity.mjs +16 -0
- package/local-cli.mjs +3 -1
- package/local-contracts.mjs +6 -1
- package/local-discipline-actions.mjs +5 -0
- package/local-discipline-batch.mjs +12 -3
- package/local-discipline-contracts.mjs +15 -3
- package/local-discipline-gate.mjs +17 -2
- package/local-discipline-repository.mjs +11 -5
- package/local-error.mjs +18 -1
- package/local-execution.mjs +2 -1
- package/local-finder-plist.mjs +87 -0
- package/local-finder-store.mjs +115 -0
- package/local-mutation-authority.mjs +13 -0
- package/local-mutation-completion-evidence.mjs +83 -0
- package/local-mutation-consumption.mjs +33 -10
- package/local-mutation-environment-observation.mjs +85 -0
- package/local-mutation-finder-observation.mjs +58 -0
- package/local-mutation-wire.mjs +26 -8
- package/local-runner-discipline.mjs +17 -3
- package/local-scope-files.mjs +3 -1
- package/local-scope-font.mjs +45 -0
- package/local-scope-repository.mjs +16 -3
- package/local-scope-zip.mjs +114 -0
- package/manifest.json +1 -1
- package/member-producer-repair-authority.mjs +48 -0
- package/model-images.d.mts +9 -0
- package/model-images.mjs +87 -0
- package/mutation-challenge-refresh.mjs +51 -0
- package/mutation-client.mjs +42 -24
- package/mutation-connection-state.mjs +17 -0
- package/package-archive.mjs +54 -0
- package/package-replacement.mjs +48 -0
- package/package.json +50 -6
- package/release-state.json +87 -45
- package/release.mjs +11 -1
- package/releases.json +30 -0
- package/remote-client.mjs +1 -1
- package/remote-transport.mjs +48 -16
- package/reuse-discipline-release.mjs +3 -1
- package/rollback-history.mjs +51 -0
- package/rollback-runner.mjs +77 -0
- package/scope-amendment-client.mjs +1 -1
- package/scope-amendment-consumption.mjs +32 -0
- package/scope-amendment-mutation.mjs +2 -3
- package/scope-amendment-observer.mjs +2 -1
- package/scope-amendment-remote.mjs +12 -2
- package/scope-amendment-retirement.mjs +54 -0
- package/scope-amendment-state.mjs +7 -4
- package/task-entry-client.mjs +1 -0
- package/version.mjs +20 -3
- package/workflow-local-runner.mjs +9 -0
- package/workflow-observation-client.mjs +11 -0
- package/workflow-observation-connection.mjs +2 -1
- package/workflow-observation-remote.mjs +26 -1
- package/workflow-observation-wire.mjs +6 -3
|
@@ -2,11 +2,14 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
2
2
|
import { disciplineRepositoryObservation, disciplineRepositoryDifferences } from './local-discipline-repository.mjs';
|
|
3
3
|
import { atomicMutationJson14, privateMutationDirectory14, mutationStateRoot14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { designRunnerBefore, designRunnerAfter } from './design-runner.mjs';
|
|
6
|
+
import { replacementRunnerBefore, replacementRunnerAfter } from './rollback-runner.mjs';
|
|
5
7
|
|
|
6
8
|
export async function runnerDisciplineBefore(input, authority) {
|
|
7
|
-
|
|
9
|
+
const packageOriginals = await replacementRunnerBefore(input, authority);
|
|
10
|
+
const designOriginals = await designRunnerBefore(input, authority);
|
|
8
11
|
const original = await disciplineRepositoryObservation(input, authority.root);
|
|
9
|
-
return { input, original: original.comparison, readOnly: authority.plan.policy.discipline
|
|
12
|
+
return { input, designOriginals, packageOriginals, original: original.comparison, readOnly: authority.plan.policy.discipline?.access === 'read-only' };
|
|
10
13
|
}
|
|
11
14
|
export async function runnerDisciplineAfter(before, authority, processResult) {
|
|
12
15
|
if (!before) return null;
|
|
@@ -28,5 +31,16 @@ export async function runnerDisciplineAfter(before, authority, processResult) {
|
|
|
28
31
|
{ code: 'ENTROROVE_READ_ONLY_EXECUTION_DRIFT', execution: { schemaVersion: 'entrorove.validator-local-runner/1.0',
|
|
29
32
|
status: 'failed', process: processResult, evidence: [], effects: 'unknown', comparison: report } });
|
|
30
33
|
}
|
|
31
|
-
|
|
34
|
+
try {
|
|
35
|
+
await replacementRunnerAfter(before.input, authority, before.original, after, changed, before.packageOriginals);
|
|
36
|
+
await designRunnerAfter(before.input, authority, changed, before.designOriginals);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
error.execution = { schemaVersion: 'entrorove.validator-local-runner/1.0', status: 'failed', process: processResult,
|
|
40
|
+
evidence: [], effects: 'unknown', comparison: report };
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
// Keep legacy non-discipline subjects compatible with their frozen Validator
|
|
44
|
+
// schema; the actual comparison and UI refusal above still ran and persisted.
|
|
45
|
+
return authority.plan.policy.discipline ? { ...report, reportDigest } : null;
|
|
32
46
|
}
|
package/local-scope-files.mjs
CHANGED
|
@@ -2,6 +2,8 @@ import { constants, promises as fs } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
4
|
import { readBudgetedProjectFile } from './local-budget-reads.mjs';
|
|
5
|
+
import { verifiedSfnt } from './local-scope-font.mjs';
|
|
6
|
+
import { verifiedZip } from './local-scope-zip.mjs';
|
|
5
7
|
|
|
6
8
|
export function scopeFail(code, message) { throw Object.assign(new Error(message), { code }); }
|
|
7
9
|
export function observedScopeRisk(files) {
|
|
@@ -135,7 +137,7 @@ export async function scopeFiles(root, requested, knownFiles = []) {
|
|
|
135
137
|
return [...found].sort();
|
|
136
138
|
}
|
|
137
139
|
export function lineCount(content) {
|
|
138
|
-
if (!content.length) return 0;
|
|
140
|
+
if (!content.length || verifiedSfnt(content) || verifiedZip(content)) return 0;
|
|
139
141
|
return content.toString('utf8').split(/\r\n|\n|\r/).length - (/[\r\n]$/.test(content.toString('utf8')) ? 1 : 0);
|
|
140
142
|
}
|
|
141
143
|
export async function packageBoundary(root, relative, input = { repositoryRoot: root }) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function checksum(bytes, start, length, adjustment = -1) {
|
|
2
|
+
let sum = 0;
|
|
3
|
+
for (let offset = start; offset < start + length; offset += 4) {
|
|
4
|
+
let word = 0;
|
|
5
|
+
for (let i = 0; i < 4; i++) word = word * 256 + (offset + i < start + length ? bytes[offset + i] : 0);
|
|
6
|
+
if (offset !== adjustment) sum = (sum + word) >>> 0;
|
|
7
|
+
}
|
|
8
|
+
return sum;
|
|
9
|
+
}
|
|
10
|
+
// Recognize an sfnt container from its complete bytes, never its name. Unknown
|
|
11
|
+
// or damaged data keeps the conservative text count; a magic prefix is no proof.
|
|
12
|
+
export function verifiedSfnt(bytes) {
|
|
13
|
+
if (bytes.length < 28 || ![0x4f54544f, 0x00010000].includes(bytes.readUInt32BE(0))) return false;
|
|
14
|
+
const count = bytes.readUInt16BE(4), end = 12 + count * 16;
|
|
15
|
+
if (!count || count > 4096 || end > bytes.length) return false;
|
|
16
|
+
const selector = Math.floor(Math.log2(count)), search = 16 * 2 ** selector;
|
|
17
|
+
if (bytes.readUInt16BE(6) !== search || bytes.readUInt16BE(8) !== selector
|
|
18
|
+
|| bytes.readUInt16BE(10) !== count * 16 - search) return false;
|
|
19
|
+
const tables = []; let previous = ''; let head;
|
|
20
|
+
for (let index = 12; index < end; index += 16) {
|
|
21
|
+
if (bytes.subarray(index, index + 4).some(byte => byte < 0x20 || byte > 0x7e)) return false;
|
|
22
|
+
const tag = bytes.toString('ascii', index, index + 4);
|
|
23
|
+
if (!/^[\x20-\x7e]{4}$/.test(tag) || tag <= previous) return false;
|
|
24
|
+
previous = tag;
|
|
25
|
+
const offset = bytes.readUInt32BE(index + 8), length = bytes.readUInt32BE(index + 12);
|
|
26
|
+
if (offset % 4 || offset < end || offset + length > bytes.length) return false;
|
|
27
|
+
const adjustment = tag === 'head' ? offset + 8 : -1;
|
|
28
|
+
if (tag === 'head') {
|
|
29
|
+
if (length !== 54 || bytes.readUInt32BE(offset) !== 0x00010000
|
|
30
|
+
|| bytes.readUInt32BE(offset + 12) !== 0x5f0f3cf5) return false;
|
|
31
|
+
head = offset;
|
|
32
|
+
}
|
|
33
|
+
if (checksum(bytes, offset, length, adjustment) !== bytes.readUInt32BE(index + 4)) return false;
|
|
34
|
+
tables.push({ offset, length });
|
|
35
|
+
}
|
|
36
|
+
if (head === undefined || checksum(bytes, 0, bytes.length) !== 0xb1b0afba) return false;
|
|
37
|
+
let covered = end;
|
|
38
|
+
for (const table of tables.sort((a, b) => a.offset - b.offset)) {
|
|
39
|
+
if (table.offset < covered) return false;
|
|
40
|
+
for (; covered < table.offset; covered++) if (bytes[covered] !== 0) return false;
|
|
41
|
+
covered = table.offset + table.length;
|
|
42
|
+
}
|
|
43
|
+
for (; covered < bytes.length; covered++) if (bytes[covered] !== 0) return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -264,17 +264,20 @@ async function configMetadata(root, relative, input) {
|
|
|
264
264
|
async function resolveLocalImport(root, relative, required = true) {
|
|
265
265
|
if (relative.startsWith('../') || relative === '..') scopeFail('ENTROROVE_PATH_UNSAFE', 'local import escapes the repository');
|
|
266
266
|
const replacements = /\.js$/.test(relative) ? [relative.replace(/\.js$/, '.ts'), relative.replace(/\.js$/, '.tsx')] : [];
|
|
267
|
-
|
|
267
|
+
const candidates = [relative, ...replacements, ...['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.json', '.d.ts', '.d.mts', '.d.cts', '/index.js', '/index.ts', '/index.d.ts'].map(ext => relative + ext)];
|
|
268
|
+
for (const candidate of candidates) {
|
|
268
269
|
const item = await scopeProbeTarget(root, candidate, dependencyReference(candidate));
|
|
269
270
|
if (item.exists && item.stat.isFile()) return candidate;
|
|
270
271
|
}
|
|
271
|
-
if (required)
|
|
272
|
+
if (required) throw Object.assign(new Error('a local import has no resolvable source file'),
|
|
273
|
+
{ code: 'ENTROROVE_IMPORT_UNRESOLVED', missingCandidates: candidates });
|
|
272
274
|
return null;
|
|
273
275
|
}
|
|
274
276
|
export async function importGraph(root, originals, input = { repositoryRoot: root }, limits = {}) {
|
|
275
277
|
const pending = originals.filter(file => file.exists !== false).map(file => file.path); const visited = new Map(); const edges = []; let dynamic = false;
|
|
276
278
|
let sourceBytes = 0;
|
|
277
279
|
const dynamicSources = [], unresolvedImports = [];
|
|
280
|
+
const pendingCreates = new Set(originals.filter(file => file.exists === false && input.scopeCreatePaths?.includes(file.path)).map(file => file.path));
|
|
278
281
|
const workspace = await workspaceIndex(root, input);
|
|
279
282
|
const metadata = new Map(workspace.metadata.map(item => [item.path, item]));
|
|
280
283
|
while (pending.length) {
|
|
@@ -300,7 +303,17 @@ export async function importGraph(root, originals, input = { repositoryRoot: roo
|
|
|
300
303
|
unresolvedImports.push(...imports.unresolvedImports.map(site => ({ path: relative, ...site })));
|
|
301
304
|
if (imports.dynamic) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
302
305
|
for (const specifier of imports.specifiers.sort()) {
|
|
303
|
-
|
|
306
|
+
let target;
|
|
307
|
+
try { target = await resolveImport(root, relative, specifier, workspace, input, aliases, configPath, declarations); }
|
|
308
|
+
catch (error) {
|
|
309
|
+
const missing = error.code === 'ENTROROVE_IMPORT_UNRESOLVED' && specifier.startsWith('.')
|
|
310
|
+
? error.missingCandidates?.filter(candidate => pendingCreates.has(candidate)) : null;
|
|
311
|
+
if (!missing?.length) throw error;
|
|
312
|
+
unresolvedImports.push({ path: relative, specifier, reason: 'approved-create-not-present', candidatePaths: missing });
|
|
313
|
+
dynamic = true;
|
|
314
|
+
if (!dynamicSources.some(source => source.path === relative)) dynamicSources.push({ path: relative, sha256: read.sha256 });
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
304
317
|
edges.push({ from: relative, to: target, external: target === null, specifier,
|
|
305
318
|
...(declarations.has(specifier) ? { declarationOnly: true, declarationPath: relative } : {}) });
|
|
306
319
|
if (target !== null && !visited.has(target)) pending.push(target);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { inflateRawSync } from 'node:zlib';
|
|
2
|
+
|
|
3
|
+
const MAX_ENTRY_BYTES = 16 * 1024 * 1024, MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
4
|
+
const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
5
|
+
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
|
6
|
+
return value >>> 0;
|
|
7
|
+
});
|
|
8
|
+
function crc32(bytes) {
|
|
9
|
+
let value = 0xffffffff;
|
|
10
|
+
for (const byte of bytes) value = crcTable[(value ^ byte) & 255] ^ (value >>> 8);
|
|
11
|
+
return (value ^ 0xffffffff) >>> 0;
|
|
12
|
+
}
|
|
13
|
+
function plainExtra(bytes) {
|
|
14
|
+
let offset = 0;
|
|
15
|
+
while (offset + 4 <= bytes.length) {
|
|
16
|
+
const kind = bytes.readUInt16LE(offset), size = bytes.readUInt16LE(offset + 2);
|
|
17
|
+
if ([1, 0x9901].includes(kind) || offset + 4 + size > bytes.length) return false;
|
|
18
|
+
offset += 4 + size;
|
|
19
|
+
}
|
|
20
|
+
return offset === bytes.length;
|
|
21
|
+
}
|
|
22
|
+
function directory(bytes) {
|
|
23
|
+
const floor = Math.max(0, bytes.length - 65557); let end = bytes.length - 22;
|
|
24
|
+
for (; end >= floor; end--) {
|
|
25
|
+
if (bytes.readUInt32LE(end) === 0x06054b50 && end + 22 + bytes.readUInt16LE(end + 20) === bytes.length) break;
|
|
26
|
+
}
|
|
27
|
+
if (end < floor || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) return null;
|
|
28
|
+
const count = bytes.readUInt16LE(end + 10), offset = bytes.readUInt32LE(end + 16), size = bytes.readUInt32LE(end + 12);
|
|
29
|
+
if (count > 10_000 || count !== bytes.readUInt16LE(end + 8) || offset + size !== end) return null;
|
|
30
|
+
const entries = []; let cursor = offset, total = 0;
|
|
31
|
+
for (let index = 0; index < count; index++) {
|
|
32
|
+
if (cursor + 46 > end || bytes.readUInt32LE(cursor) !== 0x02014b50) return null;
|
|
33
|
+
const nameLength = bytes.readUInt16LE(cursor + 28), extraLength = bytes.readUInt16LE(cursor + 30);
|
|
34
|
+
const next = cursor + 46 + nameLength + extraLength + bytes.readUInt16LE(cursor + 32);
|
|
35
|
+
const flags = bytes.readUInt16LE(cursor + 8), method = bytes.readUInt16LE(cursor + 10);
|
|
36
|
+
const compressed = bytes.readUInt32LE(cursor + 20), expanded = bytes.readUInt32LE(cursor + 24);
|
|
37
|
+
if (!nameLength || next > end || flags & ~0x080e || ![0, 8].includes(method) || bytes.readUInt16LE(cursor + 34)
|
|
38
|
+
|| compressed === 0xffffffff || expanded > MAX_ENTRY_BYTES || (total += expanded) > MAX_TOTAL_BYTES
|
|
39
|
+
|| !plainExtra(bytes.subarray(cursor + 46 + nameLength, cursor + 46 + nameLength + extraLength))) return null;
|
|
40
|
+
entries.push({ central: cursor, local: bytes.readUInt32LE(cursor + 42), flags, method, compressed, expanded,
|
|
41
|
+
crc: bytes.readUInt32LE(cursor + 16), name: bytes.subarray(cursor + 46, cursor + 46 + nameLength) });
|
|
42
|
+
cursor = next;
|
|
43
|
+
}
|
|
44
|
+
return cursor === end ? { entries, offset } : null;
|
|
45
|
+
}
|
|
46
|
+
function descriptorEnd(bytes, offset, entry, limit) {
|
|
47
|
+
for (const length of [16, 12]) {
|
|
48
|
+
const start = offset + length - 12;
|
|
49
|
+
if (offset + length > limit || length === 16 && bytes.readUInt32LE(offset) !== 0x08074b50) continue;
|
|
50
|
+
if (bytes.readUInt32LE(start) === entry.crc && bytes.readUInt32LE(start + 4) === entry.compressed
|
|
51
|
+
&& bytes.readUInt32LE(start + 8) === entry.expanded) return offset + length;
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
function entryEnd(bytes, entry, limit) {
|
|
56
|
+
const start = entry.local;
|
|
57
|
+
if (start + 30 > limit || bytes.readUInt32LE(start) !== 0x04034b50) return -1;
|
|
58
|
+
const nameLength = bytes.readUInt16LE(start + 26), extraLength = bytes.readUInt16LE(start + 28);
|
|
59
|
+
const data = start + 30 + nameLength + extraLength, end = data + entry.compressed;
|
|
60
|
+
if (end > limit || !bytes.subarray(start + 30, start + 30 + nameLength).equals(entry.name)
|
|
61
|
+
|| !plainExtra(bytes.subarray(start + 30 + nameLength, data))) return -1;
|
|
62
|
+
for (const [local, central] of [[4, 6], [6, 8], [8, 10], [10, 12], [12, 14]]) {
|
|
63
|
+
if (bytes.readUInt16LE(start + local) !== bytes.readUInt16LE(entry.central + central)) return -1;
|
|
64
|
+
}
|
|
65
|
+
for (const [field, expected] of [[14, entry.crc], [18, entry.compressed], [22, entry.expanded]]) {
|
|
66
|
+
const actual = bytes.readUInt32LE(start + field);
|
|
67
|
+
if (actual !== expected && (!(entry.flags & 8) || actual !== 0)) return -1;
|
|
68
|
+
}
|
|
69
|
+
const compressed = bytes.subarray(data, end); let expanded;
|
|
70
|
+
if (entry.method === 0) expanded = compressed;
|
|
71
|
+
else {
|
|
72
|
+
try {
|
|
73
|
+
const result = inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expanded), info: true });
|
|
74
|
+
if (result.engine.bytesWritten !== compressed.length) return -1;
|
|
75
|
+
expanded = result.buffer;
|
|
76
|
+
} catch { return -1; }
|
|
77
|
+
}
|
|
78
|
+
if (expanded.length !== entry.expanded || crc32(expanded) !== entry.crc) return -1;
|
|
79
|
+
return entry.flags & 8 ? descriptorEnd(bytes, end, entry, limit) : end;
|
|
80
|
+
}
|
|
81
|
+
// PKWARE APPNOTE 6.3.10, 4.3: verify complete ordinary ZIP records and CRCs
|
|
82
|
+
// in bounded memory. Never extract, execute entries or grant their paths access.
|
|
83
|
+
export function verifiedZip(bytes) {
|
|
84
|
+
if (bytes.length < 22 || ![0x04034b50, 0x06054b50].includes(bytes.readUInt32LE(0))) return false;
|
|
85
|
+
const found = directory(bytes); if (!found) return false;
|
|
86
|
+
let covered = 0;
|
|
87
|
+
for (const entry of found.entries.sort((a, b) => a.local - b.local)) {
|
|
88
|
+
if (entry.local !== covered) return false;
|
|
89
|
+
covered = entryEnd(bytes, entry, found.offset);
|
|
90
|
+
if (covered < 0) return false;
|
|
91
|
+
}
|
|
92
|
+
return covered === found.offset;
|
|
93
|
+
}
|
|
94
|
+
// Metadata inspection reuses the complete CRC/length validation above; no entry
|
|
95
|
+
// is written or executed. Invalid names, duplicates and links remain unknown.
|
|
96
|
+
export function zipMetadataEntries(bytes) {
|
|
97
|
+
if (!verifiedZip(bytes)) return null;
|
|
98
|
+
const found = directory(bytes), result = new Map();
|
|
99
|
+
for (const entry of found.entries) {
|
|
100
|
+
let name;
|
|
101
|
+
try { name = new TextDecoder('utf-8', { fatal: true }).decode(entry.name); } catch { return null; }
|
|
102
|
+
const mode = bytes.readUInt32LE(entry.central + 38) >>> 16, type = mode & 0o170000;
|
|
103
|
+
if (!name || name.startsWith('/') || /[\\\u0000-\u001f\u007f]/.test(name) || /^[A-Za-z]:/.test(name)
|
|
104
|
+
|| name.split('/').some(part => part === '..' || part === '.') || result.has(name)
|
|
105
|
+
|| type && ![0o100000, 0o040000].includes(type)) return null;
|
|
106
|
+
const start = entry.local + 30 + bytes.readUInt16LE(entry.local + 26) + bytes.readUInt16LE(entry.local + 28);
|
|
107
|
+
const compressed = bytes.subarray(start, start + entry.compressed);
|
|
108
|
+
const metadata = /(?:^|\/)(?:package|manifest)\.json$/.test(name);
|
|
109
|
+
if (metadata && entry.expanded > 262_144) return null;
|
|
110
|
+
result.set(name, metadata ? entry.method === 0 ? compressed
|
|
111
|
+
: inflateRawSync(compressed, { maxOutputLength: Math.max(1, entry.expanded) }) : null);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
package/manifest.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
import { observationAuthoritySignature } from './workflow-observation-wire.mjs';
|
|
3
|
+
|
|
4
|
+
const keys = ['schemaVersion', 'repairId', 'flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'leaseDigest',
|
|
5
|
+
'planId', 'planDigest', 'executionPlanSha256', 'failedObservationId', 'failureResultDigest', 'previousAcceptanceDigest',
|
|
6
|
+
'kind', 'paths', 'dependencyRepairDigest', 'failureStepId', 'baselineSettlementId', 'baselineSettlementDigest',
|
|
7
|
+
'grantId', 'sequence', 'previousGrantDigest', 'scopeDigest', 'requestId', 'issuedAt', 'expiresAt', 'authorityReceipt'];
|
|
8
|
+
const identity = ['flowId', 'ownerId', 'taskId', 'stepId', 'leaseId', 'generation', 'planId', 'planDigest', 'executionPlanSha256'];
|
|
9
|
+
const shas = ['leaseDigest', 'planDigest', 'executionPlanSha256', 'failureResultDigest', 'previousAcceptanceDigest', 'scopeDigest', 'requestId'];
|
|
10
|
+
export function memberProducerRepairStepMatches(status, authority, { settledPass = false } = {}) {
|
|
11
|
+
try {
|
|
12
|
+
const values = status?.producerRepairAuthorities;
|
|
13
|
+
if (!Array.isArray(values) || values.length > 128) return false;
|
|
14
|
+
const matching = values.filter(p => p.stepId === authority.stepId);
|
|
15
|
+
if (matching.length !== 1) return false;
|
|
16
|
+
const p = matching[0], expected = [...keys, ...(Object.hasOwn(p, 'scopeRevisionDigest') ? ['scopeRevisionDigest'] : [])];
|
|
17
|
+
if (canonical(Object.keys(p).sort()) !== canonical(expected.sort())) return false;
|
|
18
|
+
observationAuthoritySignature(p);
|
|
19
|
+
const start = Date.parse(p.issuedAt), end = Date.parse(p.expiresAt), at = Date.parse(authority.issuedAt);
|
|
20
|
+
if (p.schemaVersion !== 'entrorove.member-producer-repair/1.0' || identity.some(k => p[k] !== authority[k])
|
|
21
|
+
|| status.flow?.stepId !== p.failureStepId || status.flow.ownerId !== p.ownerId
|
|
22
|
+
|| status.flow.flowId !== p.flowId || status.flow.status !== 'running' || status.workflow?.phase !== 'execute'
|
|
23
|
+
|| status.workflow.terminalAt || ['pending', 'declined'].includes(status.workflow.authorization)
|
|
24
|
+
|| !['producer-correction', 'dependency-revalidation'].includes(p.kind)
|
|
25
|
+
|| shas.some(k => !/^[a-f0-9]{64}$/.test(p[k])) || authority.leaseDigest && authority.leaseDigest !== p.leaseDigest
|
|
26
|
+
|| !Array.isArray(p.paths) || p.paths.length > 512 || new Set(p.paths).size !== p.paths.length
|
|
27
|
+
|| canonical(p.paths) !== canonical([...p.paths].sort()) || (p.kind === 'producer-correction') !== (p.paths.length > 0)
|
|
28
|
+
|| p.paths.some(path => typeof path !== 'string' || !path || path.startsWith('/') || /[\\:\u0000-\u001f\u007f]/.test(path)
|
|
29
|
+
|| path.split('/').some(part => !part || part === '.' || part === '..'))
|
|
30
|
+
|| !Number.isSafeInteger(p.sequence) || p.sequence < 1 || !Number.isSafeInteger(p.generation) || p.generation < 1
|
|
31
|
+
|| ['grantId', 'repairId', 'failedObservationId', 'failureStepId'].some(k => typeof p[k] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(p[k]))
|
|
32
|
+
|| (p.sequence === 1 ? p.previousGrantDigest !== null : !/^[a-f0-9]{64}$/.test(p.previousGrantDigest))
|
|
33
|
+
|| !Number.isFinite(start) || !Number.isFinite(end) || start > Date.now() || end <= start || end - start > 600000
|
|
34
|
+
|| !Number.isFinite(at) || at < start || at >= end || Date.parse(authority.expiresAt) > end
|
|
35
|
+
|| !settledPass && end <= Date.now()
|
|
36
|
+
|| (p.dependencyRepairDigest !== null && !/^[a-f0-9]{64}$/.test(p.dependencyRepairDigest))
|
|
37
|
+
|| (Object.hasOwn(p, 'scopeRevisionDigest') && p.scopeRevisionDigest !== authority.scopeRevisionDigest
|
|
38
|
+
&& Object.hasOwn(authority, 'scopeRevisionDigest'))) return false;
|
|
39
|
+
if (settledPass && (authority.verificationMode !== 'member-local' || authority.runnerKeyId !== null
|
|
40
|
+
|| !authority.writeDelegation || p.kind !== 'producer-correction' || !Array.isArray(authority.paths)
|
|
41
|
+
|| !authority.paths.length || authority.paths.some(path => !p.paths.includes(path)))) return false;
|
|
42
|
+
return true;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
export function memberProducerRepairDependency(status, pass) {
|
|
46
|
+
if (!memberProducerRepairStepMatches(status, pass, { settledPass: true })) return null;
|
|
47
|
+
return status.producerRepairAuthorities.find(p => p.stepId === pass.stepId).dependencyRepairDigest;
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ModelImageInput = { url: string; detail?: 'auto'|'low'|'high'|'original' };
|
|
2
|
+
export type ModelImageMetadata = { sha256: string; mimeType: string; bytes: number; detail: 'auto'|'low'|'high'|'original' };
|
|
3
|
+
export type ModelImage = ModelImageInput & ModelImageMetadata;
|
|
4
|
+
export const MODEL_IMAGE_LIMITS: Readonly<{count: number; perImageBytes: number; totalBytes: number; requestBytes: number; maxSide: number}>;
|
|
5
|
+
export function inspectModelImage(bytes: Buffer): Omit<ModelImageMetadata,'detail'>;
|
|
6
|
+
export function normalizeModelImages(value?: unknown): ModelImage[];
|
|
7
|
+
export function modelImageManifest(images: readonly ModelImage[]): ModelImageMetadata[];
|
|
8
|
+
export function modelUserContent(goal: string, images: readonly ModelImage[]): string | Array<
|
|
9
|
+
{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail: ModelImageMetadata['detail'] } }>;
|
package/model-images.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const MODEL_IMAGE_LIMITS = Object.freeze({ count: 4, perImageBytes: 2 * 1024 * 1024,
|
|
4
|
+
totalBytes: 8 * 1024 * 1024, requestBytes: 12 * 1024 * 1024, maxSide: 8192 });
|
|
5
|
+
const DETAILS = new Set(['auto', 'low', 'high', 'original']);
|
|
6
|
+
const invalid = () => new Error('Images require bounded JPEG, PNG, GIF or WebP content with valid dimensions');
|
|
7
|
+
|
|
8
|
+
function jpegSize(bytes) {
|
|
9
|
+
let offset = 2;
|
|
10
|
+
while (offset + 4 <= bytes.length) {
|
|
11
|
+
if (bytes[offset++] !== 255) throw invalid();
|
|
12
|
+
while (bytes[offset] === 255) offset++;
|
|
13
|
+
if (offset + 3 > bytes.length) throw invalid();
|
|
14
|
+
const marker = bytes[offset++];
|
|
15
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
16
|
+
const length = bytes.readUInt16BE(offset);
|
|
17
|
+
if (length < 2 || offset + length > bytes.length) throw invalid();
|
|
18
|
+
if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) {
|
|
19
|
+
if (length < 8) throw invalid();
|
|
20
|
+
return [bytes.readUInt16BE(offset + 5), bytes.readUInt16BE(offset + 3)];
|
|
21
|
+
}
|
|
22
|
+
offset += length;
|
|
23
|
+
}
|
|
24
|
+
throw invalid();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function webpSize(bytes) {
|
|
28
|
+
if (bytes.length < 30 || bytes.readUInt32LE(4) + 8 !== bytes.length) throw invalid();
|
|
29
|
+
const chunk = bytes.toString('ascii', 12, 16), length = bytes.readUInt32LE(16);
|
|
30
|
+
if (20 + length + (length % 2) > bytes.length) throw invalid();
|
|
31
|
+
if (chunk === 'VP8X' && length === 10) return [bytes.readUIntLE(24, 3) + 1, bytes.readUIntLE(27, 3) + 1];
|
|
32
|
+
if (chunk === 'VP8 ' && length >= 10 && bytes.subarray(23,26).equals(Buffer.from([0x9d,1,0x2a]))) {
|
|
33
|
+
return [bytes.readUInt16LE(26) & 0x3fff, bytes.readUInt16LE(28) & 0x3fff];
|
|
34
|
+
}
|
|
35
|
+
if (chunk === 'VP8L' && length >= 5 && bytes[20] === 0x2f) {
|
|
36
|
+
const packed = bytes.readUInt32LE(21);
|
|
37
|
+
return [(packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1];
|
|
38
|
+
}
|
|
39
|
+
throw invalid();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Inspect content headers and dimensions; the provider still decodes the actual image. */
|
|
43
|
+
export function inspectModelImage(bytes) {
|
|
44
|
+
if (!Buffer.isBuffer(bytes) || bytes.length < 26 || bytes.length > MODEL_IMAGE_LIMITS.perImageBytes) throw invalid();
|
|
45
|
+
let mimeType, size;
|
|
46
|
+
if (bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
|
|
47
|
+
&& bytes.readUInt32BE(8) === 13 && bytes.toString('ascii',12,16) === 'IHDR') {
|
|
48
|
+
mimeType = 'image/png'; size = [bytes.readUInt32BE(16), bytes.readUInt32BE(20)];
|
|
49
|
+
} else if (bytes[0] === 255 && bytes[1] === 216) {
|
|
50
|
+
mimeType = 'image/jpeg'; size = jpegSize(bytes);
|
|
51
|
+
} else if (['GIF87a','GIF89a'].includes(bytes.toString('ascii',0,6))) {
|
|
52
|
+
mimeType = 'image/gif'; size = [bytes.readUInt16LE(6), bytes.readUInt16LE(8)];
|
|
53
|
+
} else if (bytes.toString('ascii',0,4) === 'RIFF' && bytes.toString('ascii',8,12) === 'WEBP') {
|
|
54
|
+
mimeType = 'image/webp'; size = webpSize(bytes);
|
|
55
|
+
} else throw invalid();
|
|
56
|
+
if (size.some(value => !Number.isInteger(value) || value < 1 || value > MODEL_IMAGE_LIMITS.maxSide)) throw invalid();
|
|
57
|
+
return { mimeType, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeModelImages(value = []) {
|
|
61
|
+
if (!Array.isArray(value) || value.length > MODEL_IMAGE_LIMITS.count) throw new Error('At most four input images are supported');
|
|
62
|
+
let total = 0;
|
|
63
|
+
return value.map(image => {
|
|
64
|
+
if (!image || typeof image !== 'object' || Array.isArray(image)
|
|
65
|
+
|| Object.keys(image).some(key => !['url','detail'].includes(key))
|
|
66
|
+
|| typeof image.url !== 'string' || !DETAILS.has(image.detail ?? 'auto')) throw invalid();
|
|
67
|
+
if (image.url.length > Math.ceil(MODEL_IMAGE_LIMITS.perImageBytes / 3) * 4 + 64) throw invalid();
|
|
68
|
+
const match = /^data:(image\/(?:jpeg|png|gif|webp));base64,([A-Za-z0-9+/]+={0,2})$/.exec(image.url);
|
|
69
|
+
if (!match) throw new Error('Input images must use inline base64 data URLs; remote URLs are not fetched');
|
|
70
|
+
const bytes = Buffer.from(match[2], 'base64');
|
|
71
|
+
if (bytes.toString('base64') !== match[2]) throw invalid();
|
|
72
|
+
const metadata = inspectModelImage(bytes);
|
|
73
|
+
if (metadata.mimeType !== match[1]) throw new Error('Image media type differs from its actual content');
|
|
74
|
+
total += bytes.length;
|
|
75
|
+
if (total > MODEL_IMAGE_LIMITS.totalBytes) throw new Error('Input images exceed the total byte limit');
|
|
76
|
+
return Object.freeze({ url: image.url, detail: image.detail ?? 'auto', ...metadata });
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function modelImageManifest(images) {
|
|
81
|
+
return images.map(({ sha256, mimeType, bytes, detail }) => ({ sha256, mimeType, bytes, detail }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function modelUserContent(goal, images) {
|
|
85
|
+
return images.length ? [{ type: 'text', text: goal }, ...images.map(image => ({ type: 'image_url',
|
|
86
|
+
image_url: { url: image.url, detail: image.detail } }))] : goal;
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
|
+
import { buildInvocation } from './remote-client.mjs';
|
|
3
|
+
import { mutationFail14 } from './local-mutation-files.mjs';
|
|
4
|
+
|
|
5
|
+
const fail = message => mutationFail14('ENTROROVE_MUTATION_CHALLENGE_REFRESH', message);
|
|
6
|
+
export async function refreshExpiredMutationChallenge(context, callbacks) {
|
|
7
|
+
const { state, identity, mutator, location, dependencies, fields } = context;
|
|
8
|
+
const { remote, save, challengeBinding } = callbacks, old = state.challenged.challenge;
|
|
9
|
+
if (state.issued || state.gatePassPath || Date.parse(old.expiresAt) > Date.now()) return state.challenged;
|
|
10
|
+
challengeBinding(old, identity, mutator, { live: false });
|
|
11
|
+
if (old.verificationMode !== 'member-local' || !old.writeDelegation || old.runnerKeyId !== null) {
|
|
12
|
+
fail('Expired challenge recovery requires the original member-local editing authority');
|
|
13
|
+
}
|
|
14
|
+
if (!state.refreshOriginal && state.attestation) {
|
|
15
|
+
const input = { challengeId: old.challengeId, attestation: state.attestation };
|
|
16
|
+
if (!state.pending) {
|
|
17
|
+
state.pending = { stage: 'issued', operation: 'mutation-pass-issue', input,
|
|
18
|
+
requestId: buildInvocation('mutation-pass-issue', input).requestId };
|
|
19
|
+
await save(location, state);
|
|
20
|
+
}
|
|
21
|
+
if (state.pending.stage === 'issued') {
|
|
22
|
+
try { await remote('issued', 'mutation-pass-issue', input, state, location, dependencies); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (state.pending || error.output?.status !== 'blocked'
|
|
25
|
+
|| !['ENTROROVE_MUTATION_LEASE', 'ENTROROVE_MUTATION_EXPIRED'].includes(error.output.code)
|
|
26
|
+
|| state.feedback?.acknowledgement?.stored !== true) throw error;
|
|
27
|
+
}
|
|
28
|
+
if (state.issued) return state.challenged;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (state.pending && state.pending.stage !== 'refreshed') fail('Recover only the original pending mutation request before challenge refresh');
|
|
32
|
+
if (!Array.isArray(state.attempts ?? []) || (state.attempts?.length ?? 0) >= 512) fail('Original mutation challenge history reached its bound');
|
|
33
|
+
if (!state.refreshOriginal) {
|
|
34
|
+
state.refreshOriginal = Object.fromEntries(['challenged', 'planned', 'planStarted', 'attestation', 'feedback', 'failures']
|
|
35
|
+
.filter(key => Object.hasOwn(state, key)).map(key => [key, structuredClone(state[key])]));
|
|
36
|
+
await save(location, state);
|
|
37
|
+
}
|
|
38
|
+
if (canonical(state.refreshOriginal.challenged.challenge) !== canonical(old)) fail('Original archived challenge binding changed');
|
|
39
|
+
const ref = { challengeId: old.challengeId, challengeDigest: sha256(canonical(old)) };
|
|
40
|
+
const input = { ...Object.fromEntries(fields.map(field => [field, identity[field]])),
|
|
41
|
+
...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}),
|
|
42
|
+
mutationPublicKey: mutator.mutationPublicKey, refreshExpiredChallenge: ref };
|
|
43
|
+
const refreshed = state.refreshed ?? await remote('refreshed', 'mutation-snapshot-challenge', input, state, location, dependencies);
|
|
44
|
+
challengeBinding(refreshed.challenge, identity, mutator, { live: false });
|
|
45
|
+
if (canonical(refreshed.challenge.previousChallenge) !== canonical(ref)) fail('Fresh challenge lost its exact expired predecessor');
|
|
46
|
+
state.attempts = [...(state.attempts ?? []), { ...state.refreshOriginal, archivedAt: new Date().toISOString() }];
|
|
47
|
+
for (const key of ['challenged', 'planned', 'planStarted', 'attestation', 'failures', 'refreshOriginal', 'refreshed']) delete state[key];
|
|
48
|
+
state.challenged = refreshed;
|
|
49
|
+
await save(location, state);
|
|
50
|
+
return refreshed;
|
|
51
|
+
}
|
package/mutation-client.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
3
|
import { withStateLock } from './local-state-lock.mjs';
|
|
4
4
|
import { mutationId14, mutationPaths14, mutationTarget14, mutationFail14, privateMutationDirectory14,
|
|
5
|
-
|
|
5
|
+
atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
6
6
|
import { preparePreservedMutationSigner, preservedMutationLocation } from './local-preserved-mutation-signer.mjs';
|
|
7
7
|
import { snapshotPlan14, snapshotVerify14 } from './local-mutation-snapshot.mjs';
|
|
8
8
|
import { attestMutationSnapshot14 } from './local-mutation-binding.mjs';
|
|
@@ -17,8 +17,10 @@ import { appendInvocation, readInvocations } from './local-invocations.mjs';
|
|
|
17
17
|
import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
18
18
|
import { attestMutationConsumption14, preservedMutationConsumptionState } from './local-mutation-consumption.mjs';
|
|
19
19
|
import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
|
|
20
|
+
import { readMutationConnection as readConnection } from './mutation-connection-state.mjs';
|
|
21
|
+
import { readMutationEnvironmentObservation } from './local-mutation-environment-observation.mjs';
|
|
22
|
+
import { refreshExpiredMutationChallenge } from './mutation-challenge-refresh.mjs';
|
|
20
23
|
|
|
21
|
-
const schema = 'entrorove.mutation-connection-state/1.0';
|
|
22
24
|
const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
|
|
23
25
|
const fail = message => mutationFail14('ENTROROVE_MUTATION_CONNECTION_BINDING', message);
|
|
24
26
|
const unknown = requestId => Object.assign(new Error(`Mutation connection outcome is unknown; recover original requestId=${requestId} without repeating its POST`),
|
|
@@ -36,18 +38,6 @@ function inputIdentity(input) {
|
|
|
36
38
|
if (Object.hasOwn(input, 'previousPassId')) mutationId14(input.previousPassId, 'previousPassId');
|
|
37
39
|
return { ...input, paths: mutationPaths14(input.paths) };
|
|
38
40
|
}
|
|
39
|
-
async function readConnection(location, identity) {
|
|
40
|
-
let value;
|
|
41
|
-
try { value = await readMutationJson14(location.file); }
|
|
42
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
43
|
-
if (!value) return { schemaVersion: schema, identity };
|
|
44
|
-
if (value.schemaVersion !== schema || canonical(value.identity) !== canonical(identity)
|
|
45
|
-
|| value.pending && (value.pending.requestId !== buildInvocation(value.pending.operation, value.pending.input).requestId
|
|
46
|
-
|| !['mutation-snapshot-challenge', 'mutation-pass-issue', 'mutation-pass-settle'].includes(value.pending.operation))) {
|
|
47
|
-
fail('Mutation connection journal differs from the original repository, request or physical key');
|
|
48
|
-
}
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
51
41
|
async function recoverRemote(pending, dependencies) {
|
|
52
42
|
try {
|
|
53
43
|
const token = await dependencies.readCredential(dependencies.home), origin = trustedOrigin(dependencies.apiOrigin());
|
|
@@ -90,7 +80,11 @@ async function remote(stage, operation, input, state, location, dependencies) {
|
|
|
90
80
|
}
|
|
91
81
|
}
|
|
92
82
|
delete state.pending;
|
|
83
|
+
if (result.feedback) state.feedback = result.feedback;
|
|
93
84
|
if (!response.ok || !['succeeded', 'reported'].includes(result?.output?.status)) {
|
|
85
|
+
const failure = { operation, requestId, code: result?.output?.code ?? null, ...(result?.output ? { output: result.output } : {}),
|
|
86
|
+
...(result.feedback ? { feedback: result.feedback } : {}) };
|
|
87
|
+
if (canonical(state.failures?.at(-1) ?? null) !== canonical(failure)) state.failures = [...(state.failures ?? []), failure];
|
|
94
88
|
try { await save(location, state); } catch { throw unknown(requestId); }
|
|
95
89
|
throw Object.assign(new Error(result?.output?.message ?? `Mutation connection blocked at ${operation}`),
|
|
96
90
|
{ code: result?.output?.code ?? 'ENTROROVE_MUTATION_CONNECTION_BLOCKED', output: result?.output, requestId });
|
|
@@ -147,17 +141,21 @@ async function authorizeCachedConnection(identity, pass, dependencies) {
|
|
|
147
141
|
mutationFail14('ENTROROVE_MUTATION_CONNECTION_AUTHORIZATION', 'Current account could not confirm the original active mutation flow');
|
|
148
142
|
}
|
|
149
143
|
if (!response.ok || status?.schemaVersion !== 'entrorove.flow-status/1.0' || status.flow?.flowId !== identity.flowId
|
|
150
|
-
|| status.flow.ownerId !== pass.ownerId || status.flow.stepId !== identity.stepId
|
|
144
|
+
|| status.flow.ownerId !== pass.ownerId || status.flow.stepId !== identity.stepId
|
|
145
|
+
&& !memberProducerRepairStepMatches(status, pass, { settledPass: true }) || status.flow.status !== 'running'
|
|
151
146
|
|| !['execute', 'merge', 'deliver'].includes(status.workflow?.phase) || status.workflow.terminalAt
|
|
152
147
|
|| ['pending', 'declined'].includes(status.workflow.authorization)) {
|
|
153
148
|
mutationFail14('ENTROROVE_MUTATION_CONNECTION_AUTHORIZATION', 'Current account must retain the original owned active flow and business step');
|
|
154
149
|
}
|
|
150
|
+
return status;
|
|
155
151
|
}
|
|
156
152
|
async function connect(identity, local, mutator, location, dependencies) {
|
|
157
153
|
const state = await readConnection(location, identity);
|
|
158
|
-
|
|
154
|
+
let challenged = state.challenged ?? await remote('challenged', 'mutation-snapshot-challenge',
|
|
159
155
|
{ ...Object.fromEntries(fields.map(field => [field, identity[field]])),
|
|
160
156
|
...(identity.previousPassId ? { previousPassId: identity.previousPassId } : {}), mutationPublicKey: mutator.mutationPublicKey }, state, location, dependencies);
|
|
157
|
+
challenged = await refreshExpiredMutationChallenge({ state, identity, mutator, location, dependencies, fields },
|
|
158
|
+
{ remote, save, challengeBinding });
|
|
161
159
|
const challenge = challenged.challenge;
|
|
162
160
|
challengeBinding(challenge, identity, mutator, { live: !state.gatePassPath });
|
|
163
161
|
const input = { ...local, snapshotId: challenge.snapshotId };
|
|
@@ -167,9 +165,14 @@ async function connect(identity, local, mutator, location, dependencies) {
|
|
|
167
165
|
if (canonical(stored) !== canonical(state.issued.pass)) fail('Installed mutation pass differs from its original connection');
|
|
168
166
|
await authorizeCachedConnection(identity, stored, dependencies);
|
|
169
167
|
const consumption = await preservedMutationConsumptionState({ ...input, gatePassPath: state.gatePassPath });
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
168
|
+
const expiredIncomplete = !consumption.complete && Date.parse(stored.expiresAt) <= Date.now();
|
|
169
|
+
const hasWrites = Object.values(consumption.ledger.writes).some(value => value.status === 'written');
|
|
170
|
+
if (consumption.complete || expiredIncomplete) return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: identity.flowId,
|
|
171
|
+
code: expiredIncomplete ? hasWrites ? 'ENTROROVE_MUTATION_PARTIAL_SETTLEMENT_REQUIRED' : 'ENTROROVE_MUTATION_UNUSED_SETTLEMENT_REQUIRED'
|
|
172
|
+
: 'ENTROROVE_MUTATION_SETTLEMENT_REQUIRED', gatePassPath: state.gatePassPath,
|
|
173
|
+
message: expiredIncomplete && !hasWrites
|
|
174
|
+
? 'Close only the original expired unused pass with fresh S and exact unchanged unwritten targets. Zero writes do not complete work or authorize tests; unknown pending effects remain refused'
|
|
175
|
+
: 'Settle only the actual original completed writes with fresh S. Preserve original protection, failed records and any exact unchanged unwritten targets; unknown pending effects remain refused',
|
|
173
176
|
localAction: await scopeAmendmentRefreshAction({ ...input, gatePassPath: state.gatePassPath }, stored) ?? { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
|
|
174
177
|
input: { taskId: stored.taskId, flowId: stored.flowId, stepId: stored.stepId, gatePassPath: state.gatePassPath } } };
|
|
175
178
|
passBinding(state.issued.pass, challenge, state.attestation);
|
|
@@ -226,7 +229,7 @@ async function settleMutation(repositoryRoot, input, dependencies) {
|
|
|
226
229
|
await bootstrapCloudMutationAuthority(dependencies);
|
|
227
230
|
const local = { ...input, repositoryRoot, home: dependencies.home }, original = await preservedMutationConsumptionState(local);
|
|
228
231
|
const { pass, binding } = original;
|
|
229
|
-
await authorizeCachedConnection({ flowId: pass.flowId, stepId: pass.stepId }, pass, dependencies);
|
|
232
|
+
const flowStatus = await authorizeCachedConnection({ flowId: pass.flowId, stepId: pass.stepId }, pass, dependencies);
|
|
230
233
|
const directory = path.join(binding.directory, 'settlements'); await privateMutationDirectory14(directory, { privateFrom: binding.stateRoot });
|
|
231
234
|
const location = { ...binding, file: path.join(directory, `${pass.passId}.json`) };
|
|
232
235
|
const identity = { flowId: pass.flowId, stepId: pass.stepId, passDigest: sha256(canonical(pass)),
|
|
@@ -250,17 +253,31 @@ async function settleMutation(repositoryRoot, input, dependencies) {
|
|
|
250
253
|
}
|
|
251
254
|
settlementBinding(output, pass, state.consumptionReceipt, state.settlementId);
|
|
252
255
|
if (state.settlementId === undefined) { state.settlementId = output.settlementId; await save(location, state); }
|
|
256
|
+
const remaining = state.consumptionReceipt.unwritten?.map(file => file.path);
|
|
257
|
+
const environmentObservation = await readMutationEnvironmentObservation(local, pass);
|
|
253
258
|
return { schemaVersion: 'entrorove.mutation-settlement/1.0', status: 'reported', settled: true,
|
|
254
259
|
flowId: pass.flowId, taskId: pass.taskId, passId: pass.passId, settlementId: output.settlementId,
|
|
255
260
|
consumptionReceiptDigest: output.consumptionReceiptDigest,
|
|
261
|
+
...(environmentObservation ? { environmentObservation, environmentObservationAcknowledgedByServer: false } : {}),
|
|
256
262
|
claimsVerifiedExecution: false, claimsCompletion: false, ...(state.feedback ? { feedback: state.feedback } : {}),
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
...(remaining ? { partial: true, unused: state.consumptionReceipt.writes.length === 0,
|
|
264
|
+
writtenPaths: state.consumptionReceipt.writes.map(file => file.path), unwrittenPaths: remaining } : {}),
|
|
265
|
+
...(!remaining ? { observationAction: { command: 'npx @entrorove/skill workflow-observe-step <absolute repositoryRoot>', independentValidation: 'pending',
|
|
266
|
+
input: settlementObservationInput(output, pass, flowStatus) } } : {}),
|
|
260
267
|
localAction: { command: 'npx @entrorove/skill mutation-connect <absolute repositoryRoot>',
|
|
261
|
-
input: { ...Object.fromEntries(fields.map(field => [field, pass[field]])), previousPassId: pass.passId } } };
|
|
268
|
+
input: { ...Object.fromEntries(fields.map(field => [field, pass[field]])), ...(remaining ? { paths: remaining } : {}), previousPassId: pass.passId } } };
|
|
262
269
|
});
|
|
263
270
|
}
|
|
271
|
+
function settlementObservationInput(output, pass, flowStatus) {
|
|
272
|
+
const original = Object.fromEntries(['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].map(k => [k, pass[k]]));
|
|
273
|
+
original.settlementId = output.settlementId;
|
|
274
|
+
const dependencyRepairDigest = memberProducerRepairDependency(flowStatus, pass);
|
|
275
|
+
const input = output.observationInput ?? { ...original, ...(dependencyRepairDigest ? { dependencyRepairDigest } : {}) };
|
|
276
|
+
const { dependencyRepairDigest: dependency, ...identity } = input;
|
|
277
|
+
if (canonical(identity) !== canonical(original) || dependency !== undefined && !/^[a-f0-9]{64}$/.test(dependency)
|
|
278
|
+
|| dependencyRepairDigest && dependency !== dependencyRepairDigest) fail('Observation guidance lost its original settled identity or accepted ancestor repair');
|
|
279
|
+
return input;
|
|
280
|
+
}
|
|
264
281
|
async function runMutationClient(operation, repositoryRoot, input, dependencies, work) {
|
|
265
282
|
let preflight = true;
|
|
266
283
|
try {
|
|
@@ -290,3 +307,4 @@ async function runMutationClient(operation, repositoryRoot, input, dependencies,
|
|
|
290
307
|
}
|
|
291
308
|
export const runMutationConnect = (repositoryRoot, input, dependencies) => runMutationClient('mutation-connect', repositoryRoot, input, dependencies, connectMutation);
|
|
292
309
|
export const runMutationSettle = (repositoryRoot, input, dependencies) => runMutationClient('mutation-settle', repositoryRoot, input, dependencies, settleMutation);
|
|
310
|
+
import { memberProducerRepairStepMatches, memberProducerRepairDependency } from './member-producer-repair-authority.mjs';
|