@entrorove/skill 0.1.71 → 0.1.78
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 +5 -0
- package/CHANGELOG.md +47 -0
- package/DESIGN-GATES.md +5 -9
- package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
- package/IDE-EXECUTION.md +25 -4
- package/REUSE-DISCIPLINE.md +3 -3
- package/SKILL.md +15 -4
- package/cli.mjs +8 -4
- package/cloud-mutation-authority.mjs +8 -2
- package/cloud-task-client.mjs +10 -0
- package/cloud-watch-client.mjs +8 -2
- package/design-browser-checks.mjs +18 -5
- package/design-browser-pages.mjs +96 -0
- package/design-browser-state.mjs +1 -0
- package/design-browser.mjs +36 -40
- package/design-contracts.mjs +4 -1
- package/design-declaration.mjs +3 -0
- package/design-frameworks.mjs +2 -1
- package/design-gate.mjs +3 -0
- package/design-operations.mjs +4 -1
- package/design-policy.mjs +12 -4
- package/design-release.mjs +6 -0
- package/design-research-network.mjs +82 -0
- package/design-research.mjs +8 -25
- package/design-runner.mjs +3 -0
- package/design-stage-contracts.mjs +12 -0
- package/design-stage.mjs +78 -0
- package/design-storage.mjs +63 -0
- package/fs-request-trace.mjs +67 -0
- package/local-contracts.mjs +1 -1
- package/local-discipline-repository.mjs +4 -2
- package/local-invocations.mjs +6 -0
- package/local-mutation-files.mjs +13 -5
- package/local-probe.mjs +20 -7
- package/local-recovery-budget.mjs +31 -0
- package/local-scope-repository.mjs +18 -3
- package/local-scope-signature.mjs +2 -2
- package/local-source-execution.mjs +47 -0
- package/manifest.json +1 -1
- package/mutation-challenge-refresh.mjs +3 -0
- package/mutation-client.mjs +17 -5
- package/native-client.mjs +4 -1
- package/package.json +19 -2
- package/release-source-groups.mjs +7 -0
- package/release-state.json +70 -53
- package/release.mjs +9 -3
- package/releases.json +42 -0
- package/remote-capability-cache.mjs +20 -0
- package/remote-client.mjs +25 -5
- package/remote-transport.mjs +14 -0
- package/scope-amendment-client.mjs +90 -22
- package/scope-amendment-observer.mjs +9 -3
- package/scope-amendment-reference.mjs +22 -4
- package/scope-amendment-remote.mjs +20 -6
- package/scope-amendment-retirement.mjs +4 -2
- package/scope-amendment-state.mjs +10 -6
- package/scope-context-client.mjs +24 -8
- package/scope-context-journal.mjs +13 -6
- package/scope-context-remote.mjs +20 -9
- package/scope-context-state.mjs +4 -1
- package/scope-context-wire.mjs +9 -1
- package/scope-evidence-local.mjs +52 -0
- package/scope-evidence-remote.mjs +87 -0
- package/scope-evidence-wire.mjs +120 -0
- package/scope-runner-client.mjs +92 -35
- package/scope-runner-journal.mjs +109 -0
- package/scope-write-observation.d.mts +4 -0
- package/scope-write-observation.mjs +43 -0
- package/service-prerequisite.mjs +87 -0
- package/task-entry-client.mjs +3 -0
- package/workflow-observation-client.mjs +2 -0
- package/workflow-observation-remote.mjs +13 -5
package/scope-context-remote.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFe
|
|
|
6
6
|
import { readInvocations } from './local-invocations.mjs';
|
|
7
7
|
import { saveContextJournal } from './scope-context-state.mjs';
|
|
8
8
|
import { contextFail } from './scope-context-wire.mjs';
|
|
9
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
10
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
9
11
|
|
|
10
12
|
const unknown = requestId => Object.assign(new Error('Context recovery outcome is unknown; query its original request with GET only'),
|
|
11
13
|
{ code: 'ENTROROVE_SCOPE_CONTEXT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
@@ -15,6 +17,8 @@ async function recover(pending, dependencies) {
|
|
|
15
17
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
16
18
|
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
17
19
|
const result = parseTransportResponse(response, 'original context recovery status');
|
|
20
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
21
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
18
22
|
if (result?.output?.requestId !== pending.requestId || !response.ok || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
|
|
19
23
|
if (pending.operation !== 'scope-runner-context-status' || result.feedback) {
|
|
20
24
|
result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
|
|
@@ -22,7 +26,8 @@ async function recover(pending, dependencies) {
|
|
|
22
26
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
23
27
|
}
|
|
24
28
|
return { response, result };
|
|
25
|
-
} catch { throw unknown(pending.requestId)
|
|
29
|
+
} catch (error) { throw Object.assign(unknown(pending.requestId),
|
|
30
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
26
31
|
}
|
|
27
32
|
async function notSent(pending, home) {
|
|
28
33
|
try {
|
|
@@ -30,31 +35,37 @@ async function notSent(pending, home) {
|
|
|
30
35
|
return !rows.length || rows.at(-1).event === 'not-sent';
|
|
31
36
|
} catch (error) { return error.code === 'ENOENT'; }
|
|
32
37
|
}
|
|
33
|
-
export async function contextRemote(stage, operation, input, state, location, dependencies) {
|
|
38
|
+
export async function contextRemote(stage, operation, input, state, location, dependencies, save = saveContextJournal) {
|
|
34
39
|
const requestId = buildInvocation(operation, input).requestId; let response, result;
|
|
35
|
-
|
|
40
|
+
const existing = Boolean(state.pending);
|
|
41
|
+
if (existing) {
|
|
36
42
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
|
|
37
43
|
|| canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
|
|
44
|
+
}
|
|
45
|
+
if (existing && !await retryKnownPrerequisite(state, location, save)) {
|
|
46
|
+
consumeRecovery(state, 'status'); await save(location, state);
|
|
38
47
|
({ response, result } = await recover(state.pending, dependencies));
|
|
39
|
-
}
|
|
40
|
-
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
if (!existing) { state.pending = { stage, operation, input, requestId }; await save(location, state); }
|
|
41
51
|
try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
|
|
42
52
|
catch (error) {
|
|
53
|
+
if (await retainPrerequisite(error, state, location, save)) throw error;
|
|
43
54
|
if (await notSent(state.pending, dependencies.home)) {
|
|
44
|
-
delete state.pending; await
|
|
55
|
+
delete state.pending; await save(location, state);
|
|
45
56
|
throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
|
|
46
57
|
{ code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
47
58
|
}
|
|
48
|
-
throw unknown(requestId);
|
|
59
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
49
60
|
}
|
|
50
61
|
}
|
|
51
62
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
52
63
|
if (result.feedback) state.feedback = result.feedback;
|
|
53
64
|
if (!response.ok || !['succeeded', 'needs-input'].includes(result.output.status)) {
|
|
54
|
-
state.rejection = { requestId, output: result.output }; delete state.pending; await
|
|
65
|
+
state.rejection = { requestId, output: result.output }; delete state.pending; await save(location, state);
|
|
55
66
|
throw Object.assign(new Error('Original context request was rejected; preserve its exact evidence and history'),
|
|
56
67
|
{ code: result.output.code ?? 'ENTROROVE_SCOPE_CONTEXT_REJECTED', requestId, output: result.output });
|
|
57
68
|
}
|
|
58
|
-
state[stage] = result.output.result ?? result.output; delete state.pending; await
|
|
69
|
+
state[stage] = result.output.result ?? result.output; delete state.pending; await save(location, state);
|
|
59
70
|
return state[stage];
|
|
60
71
|
}
|
package/scope-context-state.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { mutationHome14, mutationFile14, readMutationJson14, atomicMutationJson1
|
|
|
8
8
|
import { trustedOrigin } from './remote-transport.mjs';
|
|
9
9
|
import { contextExact, contextFail, contextList, validateContextAuthority } from './scope-context-wire.mjs';
|
|
10
10
|
import { hydrateCloudMutationAuthority, bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
11
|
+
import { readScopeEvidence } from './scope-evidence-local.mjs';
|
|
11
12
|
|
|
12
13
|
export async function contextPrivate(file, final = 'file', maximumBytes = 8_000_000) {
|
|
13
14
|
const item = await mutationFile14(file, { final });
|
|
@@ -53,6 +54,7 @@ export async function effectiveScopeContexts(location) {
|
|
|
53
54
|
for (const name of names) {
|
|
54
55
|
const file = path.join(directory, name); await contextPrivate(file, 'file', 1_000_000);
|
|
55
56
|
const saved = await readMutationJson14(file), checked = validateContextAuthority(saved.record, location, current);
|
|
57
|
+
if (checked.record.attestationRef) await readScopeEvidence(location, checked.record.attestationRef);
|
|
56
58
|
if (saved.revisionDigest !== checked.revisionDigest || name !== `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`) contextFail('Context history checksum or order changed');
|
|
57
59
|
current = checked;
|
|
58
60
|
}
|
|
@@ -68,6 +70,7 @@ export async function retainContextAuthority(location, effective) {
|
|
|
68
70
|
const current = await effectiveScopeContexts(location);
|
|
69
71
|
if (current?.revisionDigest === effective?.revisionDigest && canonical(current.record) === canonical(effective.record)) return current;
|
|
70
72
|
const checked = validateContextAuthority(effective?.record, location, current);
|
|
73
|
+
if (checked.record.attestationRef) await readScopeEvidence(location, checked.record.attestationRef);
|
|
71
74
|
if (effective.revisionDigest !== checked.revisionDigest) contextFail('Applied context authority checksum changed');
|
|
72
75
|
const directory = path.join(location.base, 'context-authority'), file = path.join(directory, `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`);
|
|
73
76
|
await atomicMutationJson14(file, checked, { privateFrom: location.stateRoot }); return checked;
|
|
@@ -77,7 +80,7 @@ export async function readContextJournal(location, input) {
|
|
|
77
80
|
await contextPrivate(location.file);
|
|
78
81
|
const value = await readMutationJson14(location.file);
|
|
79
82
|
if (value.schemaVersion !== 'entrorove.scope-context-journal/1.0' || value.localBindingDigest !== location.localBindingDigest) contextFail('Context journal cannot replace its original identity');
|
|
80
|
-
if (value.pending && canonical(value.input) !== canonical(input)) contextFail('Recover the original pending context request before another operation');
|
|
83
|
+
if ((value.pending || value.evidencePending) && canonical(value.input) !== canonical(input)) contextFail('Recover the original pending context request before another operation');
|
|
81
84
|
return canonical(value.input) === canonical(input) ? value : { previous: value };
|
|
82
85
|
} catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
|
|
83
86
|
}
|
package/scope-context-wire.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
2
|
import { scopeFail, scopePath } from './local-scope-files.mjs';
|
|
3
3
|
import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
4
|
+
import { evidenceReference } from './scope-evidence-wire.mjs';
|
|
4
5
|
|
|
5
6
|
const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
6
7
|
const hash = /^[a-f0-9]{64}$/;
|
|
@@ -51,7 +52,8 @@ export function contextInput(input) {
|
|
|
51
52
|
export const originalContextDigest = location => sha256(canonical({ localBindingDigest: location.localBindingDigest,
|
|
52
53
|
importContexts: location.binding.importContexts }));
|
|
53
54
|
export function validateContextAuthority(value, location, previous) {
|
|
54
|
-
const
|
|
55
|
+
const version = value?.schemaVersion === 'entrorove.scope-context-authority/2.0' ? '2.0' : '1.0';
|
|
56
|
+
const record = scopeRevisionAuthority(value, `entrorove.scope-context-authority/${version}`);
|
|
55
57
|
const expectedRevision = (previous?.record.revision ?? 0) + 1;
|
|
56
58
|
if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => record[field] !== location.binding[field])
|
|
57
59
|
|| record.localBindingDigest !== location.localBindingDigest || record.revision !== expectedRevision
|
|
@@ -64,6 +66,12 @@ export function validateContextAuthority(value, location, previous) {
|
|
|
64
66
|
contextEvidence(record.evidence);
|
|
65
67
|
if (previous && ['ownerId', 'taskDigest', 'chainDigest'].some(field => record[field] !== previous.record[field])
|
|
66
68
|
|| record.importContexts.some(context => !record.evidence.some(file => file.path === context.configPath))) contextFail('Context authority lost its original task or actual configuration evidence');
|
|
69
|
+
if (version === '2.0') {
|
|
70
|
+
const reference = evidenceReference(record.attestationRef, undefined, location);
|
|
71
|
+
if (reference.kind !== 'scope-context' || Object.hasOwn(record, 'attestation')
|
|
72
|
+
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest', 'proposalId', 'proposalDigest', 'challengeId'].some(field => record[field] !== reference[field])
|
|
73
|
+
|| !hash.test(record.challengeDigest ?? '')) contextFail('Compact context authority lost its exact evidence binding');
|
|
74
|
+
}
|
|
67
75
|
return { record, revisionDigest: sha256(canonical(record)) };
|
|
68
76
|
}
|
|
69
77
|
export function contextChallenge(value, location, proposal, { allowExpired = false } = {}) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { mutationFile14, writeMutationFile14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { maximumScopeEvidenceBytes, evidenceFail, verifyEvidenceData } from './scope-evidence-wire.mjs';
|
|
5
|
+
|
|
6
|
+
function evidencePath(location, descriptor) {
|
|
7
|
+
if (!/^[a-f0-9]{64}$/.test(descriptor?.sha256 ?? '')) evidenceFail('Scope evidence requires its exact SHA256 derived private data file');
|
|
8
|
+
return path.join(location.base, 'evidence-data', `${descriptor.sha256}.json`);
|
|
9
|
+
}
|
|
10
|
+
async function privateEvidence(file) {
|
|
11
|
+
const item = await mutationFile14(file);
|
|
12
|
+
if (process.getuid && item.stat.uid !== process.getuid() || process.platform !== 'win32' && (item.stat.mode & 0o777) !== 0o600
|
|
13
|
+
|| item.stat.nlink !== 1 || item.stat.size < 1 || item.stat.size > maximumScopeEvidenceBytes) evidenceFail('Scope evidence must remain a bounded private single-link data file');
|
|
14
|
+
const directory = await mutationFile14(path.dirname(file), { final: 'directory' });
|
|
15
|
+
if (process.getuid && directory.stat.uid !== process.getuid()
|
|
16
|
+
|| process.platform !== 'win32' && (directory.stat.mode & 0o777) !== 0o700) evidenceFail('Scope evidence directory must remain private');
|
|
17
|
+
return item;
|
|
18
|
+
}
|
|
19
|
+
export async function readScopeEvidence(location, descriptor) {
|
|
20
|
+
const file = evidencePath(location, descriptor), before = await privateEvidence(file);
|
|
21
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
22
|
+
try {
|
|
23
|
+
const opened = await handle.stat();
|
|
24
|
+
const changed = stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode', 'nlink', 'uid'].some(field => stat[field] !== before.stat[field]);
|
|
25
|
+
if (changed(opened) || opened.size !== descriptor.canonicalBytes) evidenceFail('Scope evidence changed before its bounded private read');
|
|
26
|
+
const buffer = Buffer.alloc(opened.size + 1); let length = 0;
|
|
27
|
+
while (length < buffer.length) {
|
|
28
|
+
const chunk = await handle.read(buffer, length, buffer.length - length, length);
|
|
29
|
+
if (!chunk.bytesRead) break; length += chunk.bytesRead;
|
|
30
|
+
}
|
|
31
|
+
const bytes = buffer.subarray(0, length), after = await handle.stat(), latest = await privateEvidence(file);
|
|
32
|
+
if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode', 'nlink', 'uid']
|
|
33
|
+
.some(field => stat[field] !== before.stat[field])) || latest.ancestors.length !== before.ancestors.length
|
|
34
|
+
|| latest.ancestors.some((item, index) => item.path !== before.ancestors[index].path
|
|
35
|
+
|| item.stat.dev !== before.ancestors[index].stat.dev || item.stat.ino !== before.ancestors[index].stat.ino)) evidenceFail('Scope evidence changed during its stable private read');
|
|
36
|
+
let value;
|
|
37
|
+
try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)); }
|
|
38
|
+
catch { evidenceFail('Scope evidence data has invalid UTF8 JSON'); }
|
|
39
|
+
if (!verifyEvidenceData(value, descriptor, location).equals(bytes)) evidenceFail('Scope evidence data lost its exact canonical bytes');
|
|
40
|
+
return value;
|
|
41
|
+
} finally { await handle.close(); }
|
|
42
|
+
}
|
|
43
|
+
export async function retainScopeEvidence(location, descriptor, value) {
|
|
44
|
+
const bytes = verifyEvidenceData(value, descriptor, location), file = evidencePath(location, descriptor);
|
|
45
|
+
try { await readScopeEvidence(location, descriptor); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code !== 'ENOENT') throw error;
|
|
48
|
+
await writeMutationFile14(file, bytes, { privateFrom: location.stateRoot });
|
|
49
|
+
await readScopeEvidence(location, descriptor);
|
|
50
|
+
}
|
|
51
|
+
return descriptor;
|
|
52
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
|
+
import { contextRemote } from './scope-context-remote.mjs';
|
|
3
|
+
import { readScopeEvidence, retainScopeEvidence } from './scope-evidence-local.mjs';
|
|
4
|
+
import { describeScopeEvidence, evidenceBytes, evidenceFail, evidenceUpload, evidenceReference } from './scope-evidence-wire.mjs';
|
|
5
|
+
import { requestTransport, putScopeEvidence, parseTransportResponse } from './remote-transport.mjs';
|
|
6
|
+
import { ideClientHeaders } from './ide-presentation.mjs';
|
|
7
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
8
|
+
|
|
9
|
+
const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
|
|
10
|
+
{ code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
|
|
11
|
+
export async function saveLocalScopeEvidence(value, kind, challenge, state, location, save) {
|
|
12
|
+
const described = describeScopeEvidence(kind, value, challenge, location);
|
|
13
|
+
await retainScopeEvidence(location, described.descriptor, value);
|
|
14
|
+
state.evidenceDescriptor = described.descriptor; state.evidenceSignature = described.signature;
|
|
15
|
+
state.attestationDigest = described.descriptor.sha256;
|
|
16
|
+
delete state.attestation;
|
|
17
|
+
await save(location, state);
|
|
18
|
+
}
|
|
19
|
+
function resultReference(value, upload, descriptor, location) {
|
|
20
|
+
if (value?.schemaVersion !== 'entrorove.scope-evidence-upload-status/1.0' || value.uploadId !== upload.uploadId
|
|
21
|
+
|| value.grantsExecutionAuthority !== false || value.expiresAt !== upload.expiresAt
|
|
22
|
+
|| !['reserved', 'receiving', 'stored', 'rejected'].includes(value.status)) throw unknown(upload.uploadId);
|
|
23
|
+
if (value.status !== 'stored') return null;
|
|
24
|
+
const reference = evidenceReference(value.evidenceReference, descriptor, location);
|
|
25
|
+
if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
|
|
26
|
+
return reference;
|
|
27
|
+
}
|
|
28
|
+
async function acceptResult(response, upload, state, location, save, sentPut = false) {
|
|
29
|
+
let value;
|
|
30
|
+
try { value = parseTransportResponse(response, 'scope evidence upload'); }
|
|
31
|
+
catch { throw unknown(upload.uploadId); }
|
|
32
|
+
const prerequisite = prerequisiteError(response, value, { uploadId: upload.uploadId });
|
|
33
|
+
if (prerequisite) {
|
|
34
|
+
if (sentPut && await retainPrerequisite(prerequisite, state, location, save, 'evidencePending')) throw prerequisite;
|
|
35
|
+
throw Object.assign(unknown(upload.uploadId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
36
|
+
}
|
|
37
|
+
const reference = resultReference(value, upload, state.evidenceDescriptor, location);
|
|
38
|
+
if (reference) {
|
|
39
|
+
state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
|
|
40
|
+
await save(location, state); return reference;
|
|
41
|
+
}
|
|
42
|
+
if (value.status === 'rejected' && /^ENTROROVE_EVIDENCE_[A-Z_]+$/.test(value.code ?? '')) {
|
|
43
|
+
state.evidenceResult = value; state.rejection = { uploadId: upload.uploadId, output: value };
|
|
44
|
+
delete state.evidencePending; await save(location, state);
|
|
45
|
+
throw Object.assign(new Error('The original evidence reservation was rejected; preserve it before another signed attempt'),
|
|
46
|
+
{ code: value.code, uploadId: upload.uploadId, output: value });
|
|
47
|
+
}
|
|
48
|
+
throw unknown(upload.uploadId);
|
|
49
|
+
}
|
|
50
|
+
export async function ensureScopeEvidence(state, location, dependencies, challenge, save) {
|
|
51
|
+
const descriptor = state.evidenceDescriptor;
|
|
52
|
+
if (!descriptor || !state.evidenceSignature) evidenceFail('Original signed evidence descriptor is unavailable');
|
|
53
|
+
const value = await readScopeEvidence(location, descriptor);
|
|
54
|
+
if (state.attestationRef) return evidenceReference(state.attestationRef, descriptor, location);
|
|
55
|
+
if (!state.evidencePrepared) await contextRemote('evidencePrepared', 'scope-evidence-prepare', {
|
|
56
|
+
flowId: descriptor.flowId, descriptor, signature: state.evidenceSignature }, state, location, dependencies, save);
|
|
57
|
+
const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
|
|
58
|
+
const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
|
|
59
|
+
const origin = dependencies.apiOrigin();
|
|
60
|
+
const existing = Boolean(state.evidencePending);
|
|
61
|
+
if (existing) {
|
|
62
|
+
if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
|
|
63
|
+
|| state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
|
|
64
|
+
}
|
|
65
|
+
if (existing && !await retryKnownPrerequisite(state, location, save, 'evidencePending')) {
|
|
66
|
+
let response;
|
|
67
|
+
try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
|
|
68
|
+
catch { throw unknown(upload.uploadId); }
|
|
69
|
+
return acceptResult(response, upload, state, location, save);
|
|
70
|
+
}
|
|
71
|
+
if (!existing) { state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
|
|
72
|
+
await save(location, state); }
|
|
73
|
+
let response; const sentPut = Date.parse(upload.expiresAt) > Date.now();
|
|
74
|
+
try {
|
|
75
|
+
if (!sentPut) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
|
|
76
|
+
else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
|
|
77
|
+
} catch { throw unknown(upload.uploadId); }
|
|
78
|
+
return acceptResult(response, upload, state, location, save, sentPut);
|
|
79
|
+
}
|
|
80
|
+
export async function validateAppliedEvidence(record, state, location, challenge, kind) {
|
|
81
|
+
const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
|
|
82
|
+
state.evidenceDescriptor, location);
|
|
83
|
+
if (reference.kind !== kind || canonical(reference) !== canonical(state.attestationRef)
|
|
84
|
+
|| record.challengeId !== challenge.challengeId || record.challengeDigest !== sha256(canonical(challenge))
|
|
85
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== challenge[field])) evidenceFail('Applied authority differs from its original uploaded S evidence');
|
|
86
|
+
return readScopeEvidence(location, reference);
|
|
87
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createPublicKey, sign, verify } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
4
|
+
|
|
5
|
+
export const maximumScopeEvidenceBytes = 16_000_000;
|
|
6
|
+
const hash = /^[a-f0-9]{64}$/, id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
7
|
+
const common = ['kind', 'flowId', 'challengeId', 'runnerId', 'repositoryId', 'keyId', 'sha256', 'canonicalBytes'];
|
|
8
|
+
export const evidenceFail = message => { throw Object.assign(new Error(message), { code: 'ENTROROVE_SCOPE_EVIDENCE_BINDING' }); };
|
|
9
|
+
function exact(value, fields) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
11
|
+
|| Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) evidenceFail('Scope evidence fields are invalid');
|
|
12
|
+
}
|
|
13
|
+
function validString(value) {
|
|
14
|
+
if (value.includes('\0') || Buffer.byteLength(value) > maximumScopeEvidenceBytes) evidenceFail('Scope evidence string exceeds its valid bounded UTF8 representation');
|
|
15
|
+
for (let index = 0; index < value.length; index++) {
|
|
16
|
+
const code = value.charCodeAt(index);
|
|
17
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
18
|
+
const next = value.charCodeAt(++index);
|
|
19
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) evidenceFail('Scope evidence contains an invalid Unicode surrogate');
|
|
20
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) evidenceFail('Scope evidence contains an invalid Unicode surrogate');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function evidenceBytes(value) {
|
|
24
|
+
const stack = [[value, 0, false]], ancestors = new Set(); let nodes = 0;
|
|
25
|
+
while (stack.length) {
|
|
26
|
+
const [item, depth, leaving] = stack.pop();
|
|
27
|
+
if (leaving) { ancestors.delete(item); continue; }
|
|
28
|
+
if (++nodes > 250_000 || depth > 64) evidenceFail('Scope evidence exceeds its bounded structure');
|
|
29
|
+
if (typeof item === 'string') { validString(item); continue; }
|
|
30
|
+
if (item === null || typeof item === 'boolean') continue;
|
|
31
|
+
if (typeof item === 'number' && Number.isFinite(item)) continue;
|
|
32
|
+
if (!item || typeof item !== 'object' || ancestors.has(item)
|
|
33
|
+
|| !Array.isArray(item) && ![Object.prototype, null].includes(Object.getPrototypeOf(item))) evidenceFail('Scope evidence requires finite acyclic JSON');
|
|
34
|
+
ancestors.add(item); stack.push([item, depth, true]);
|
|
35
|
+
const fields = Object.getOwnPropertyDescriptors(item), keys = Reflect.ownKeys(fields);
|
|
36
|
+
if (Array.isArray(item) ? item.length > 100_000 : keys.length > 1024) evidenceFail('Scope evidence collection exceeds its bounded structure');
|
|
37
|
+
for (const key of keys) {
|
|
38
|
+
if (Array.isArray(item) && key === 'length') continue;
|
|
39
|
+
const field = fields[key];
|
|
40
|
+
if (typeof key !== 'string' || !Object.hasOwn(field, 'value') || !field.enumerable) evidenceFail('Scope evidence refuses accessors and non-JSON properties');
|
|
41
|
+
validString(key);
|
|
42
|
+
stack.push([field.value, depth + 1, false]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const bytes = Buffer.from(canonical(value));
|
|
46
|
+
if (!bytes.length || bytes.length > maximumScopeEvidenceBytes) evidenceFail('Scope evidence exceeds its separate sixteen million byte data limit');
|
|
47
|
+
return bytes;
|
|
48
|
+
}
|
|
49
|
+
export function evidenceFields(value) {
|
|
50
|
+
if (!['scope-context', 'scope-revision', 'scope-refresh', 'scope-observation'].includes(value?.kind)) evidenceFail('Scope evidence kind is invalid');
|
|
51
|
+
return [...common, ...(value.kind === 'scope-observation' ? ['stepId'] : ['proposalId', 'proposalDigest'])];
|
|
52
|
+
}
|
|
53
|
+
export function validateEvidenceIdentity(value, expected, location) {
|
|
54
|
+
for (const field of evidenceFields(value)) {
|
|
55
|
+
if (field === 'canonicalBytes') {
|
|
56
|
+
if (!Number.isSafeInteger(value[field]) || value[field] < 1 || value[field] > maximumScopeEvidenceBytes) evidenceFail('Scope evidence byte length is invalid');
|
|
57
|
+
} else if (!(['sha256', 'keyId', 'proposalDigest'].includes(field) ? hash : id).test(value[field] ?? '')) evidenceFail('Scope evidence identity is invalid');
|
|
58
|
+
if (expected && value[field] !== expected[field]) evidenceFail('Scope evidence reference replaced its exact original data identity');
|
|
59
|
+
}
|
|
60
|
+
if (location && ['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => value[field] !== location.binding[field])) {
|
|
61
|
+
evidenceFail('Scope evidence replaced its original local signer binding');
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
function signature(value, key) {
|
|
66
|
+
const { signature: encoded, ...core } = value ?? {};
|
|
67
|
+
if (typeof encoded !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(encoded)
|
|
68
|
+
|| Buffer.from(encoded, 'base64url').toString('base64url') !== encoded
|
|
69
|
+
|| !verify(null, Buffer.from(canonical(core)), key, Buffer.from(encoded, 'base64url'))) evidenceFail('Original scope evidence S signature changed');
|
|
70
|
+
}
|
|
71
|
+
export function verifyEvidenceData(value, descriptor, location) {
|
|
72
|
+
validateEvidenceIdentity(descriptor, undefined, location);
|
|
73
|
+
const bytes = evidenceBytes(value);
|
|
74
|
+
if (sha256(bytes) !== descriptor.sha256 || bytes.length !== descriptor.canonicalBytes) evidenceFail('Scope evidence hash or canonical UTF8 length changed');
|
|
75
|
+
const key = createPublicKey({ key: Buffer.from(location.binding.publicKey, 'base64'), type: 'spki', format: 'der' });
|
|
76
|
+
if (sha256(key.export({ type: 'spki', format: 'der' })) !== descriptor.keyId) evidenceFail('Scope evidence original public key changed');
|
|
77
|
+
signature(value, key);
|
|
78
|
+
const observation = descriptor.kind === 'scope-observation', inner = observation ? value : value.scopeAttestation;
|
|
79
|
+
if (!observation) signature(inner, key);
|
|
80
|
+
if (inner?.schemaVersion !== 'entrorove.scope-facts/1.0'
|
|
81
|
+
|| ['flowId', 'repositoryId', 'keyId'].some(field => inner[field] !== descriptor[field])
|
|
82
|
+
|| observation && (inner.challenge !== descriptor.challengeId || inner.stepId !== descriptor.stepId)
|
|
83
|
+
|| !observation && value.proposalDigest !== descriptor.proposalDigest
|
|
84
|
+
|| descriptor.kind === 'scope-context' && (value.schemaVersion !== 'entrorove.scope-context-attestation/1.0' || value.challengeId !== descriptor.challengeId)
|
|
85
|
+
|| ['scope-revision', 'scope-refresh'].includes(descriptor.kind) && (value.schemaVersion !== 'entrorove.scope-revision-attestation/1.0'
|
|
86
|
+
|| value.proposalId !== descriptor.proposalId || value.purpose !== (descriptor.kind === 'scope-refresh' ? 'scope-revision-refresh' : 'scope-revision'))
|
|
87
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => descriptor[field] !== undefined && inner[field] !== descriptor[field])) {
|
|
88
|
+
evidenceFail('Scope evidence has another original kind, task or challenge');
|
|
89
|
+
}
|
|
90
|
+
return bytes;
|
|
91
|
+
}
|
|
92
|
+
export function describeScopeEvidence(kind, value, challenge, location) {
|
|
93
|
+
const bytes = evidenceBytes(value), observation = kind === 'scope-observation';
|
|
94
|
+
const descriptor = { schemaVersion: 'entrorove.scope-evidence-descriptor/1.0', kind, flowId: challenge.flowId,
|
|
95
|
+
...(observation ? { stepId: challenge.stepId } : { proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest }),
|
|
96
|
+
challengeId: observation ? challenge.challenge : challenge.challengeId, runnerId: location.binding.runnerId,
|
|
97
|
+
repositoryId: location.binding.repositoryId, keyId: location.binding.keyId, sha256: sha256(bytes), canonicalBytes: bytes.length };
|
|
98
|
+
verifyEvidenceData(value, descriptor, location);
|
|
99
|
+
const inner = observation ? value : value.scopeAttestation;
|
|
100
|
+
if (['flowId', 'ownerId', 'taskDigest', 'chainDigest', 'repositoryId', 'stepId', 'challenge'].some(field => inner[field] !== challenge[field])
|
|
101
|
+
|| Date.parse(inner.expiresAt) > Date.parse(challenge.expiresAt)) evidenceFail('Scope evidence differs from its exact original challenge');
|
|
102
|
+
return { descriptor, signature: sign(null, Buffer.from(canonical(descriptor)), location.key).toString('base64url') };
|
|
103
|
+
}
|
|
104
|
+
export function evidenceReference(value, expected, location) {
|
|
105
|
+
exact(value, ['schemaVersion', 'evidenceId', ...evidenceFields(value), 'ownerId', 'taskDigest', 'chainDigest']);
|
|
106
|
+
if (value.schemaVersion !== 'entrorove.scope-evidence-reference/1.0' || !id.test(value.evidenceId ?? '')
|
|
107
|
+
|| !id.test(value.ownerId ?? '') || !hash.test(value.taskDigest ?? '') || !hash.test(value.chainDigest ?? '')) evidenceFail('Scope evidence reference is invalid');
|
|
108
|
+
return validateEvidenceIdentity(value, expected, location);
|
|
109
|
+
}
|
|
110
|
+
export function evidenceUpload(value, descriptor, location, challenge) {
|
|
111
|
+
const upload = scopeRevisionAuthority(value, 'entrorove.scope-evidence-upload/1.0');
|
|
112
|
+
exact(upload, ['schemaVersion', ...evidenceFields(upload), 'uploadId', 'ownerId', 'taskDigest', 'chainDigest', 'issuedAt', 'expiresAt', 'grantsExecutionAuthority', 'authorityReceipt']);
|
|
113
|
+
validateEvidenceIdentity(upload, descriptor, location);
|
|
114
|
+
const issued = Date.parse(upload.issuedAt), expires = Date.parse(upload.expiresAt);
|
|
115
|
+
if (!id.test(upload.uploadId ?? '') || upload.grantsExecutionAuthority !== false
|
|
116
|
+
|| ['ownerId', 'taskDigest', 'chainDigest'].some(field => upload[field] !== challenge[field])
|
|
117
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || expires <= issued || expires - issued > 90_000
|
|
118
|
+
|| expires > Date.parse(challenge.expiresAt)) evidenceFail('Scope evidence upload replaced its original challenge or authority');
|
|
119
|
+
return upload;
|
|
120
|
+
}
|
package/scope-runner-client.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import { createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign } from 'node:crypto';
|
|
2
3
|
import { constants, promises as fs } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -6,8 +7,12 @@ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-file
|
|
|
6
7
|
import { withStateLock } from './local-state-lock.mjs';
|
|
7
8
|
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
8
9
|
import { probe } from './local-probe.mjs';
|
|
9
|
-
import {
|
|
10
|
+
import { recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
11
|
+
import { contextRemote } from './scope-context-remote.mjs';
|
|
12
|
+
import { scopeRunnerJournal, saveScopeRunnerJournal, renewScopeRunnerChallenge, validateScopeRunnerChallenge } from './scope-runner-journal.mjs';
|
|
13
|
+
import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
|
|
10
14
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
15
|
+
import { mutationFile14 } from './local-mutation-files.mjs';
|
|
11
16
|
|
|
12
17
|
async function privateDirectory(directory) {
|
|
13
18
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -54,38 +59,7 @@ async function signer(directory, identity, dependencies) {
|
|
|
54
59
|
}
|
|
55
60
|
return { binding, key, signerConfigPath: path.join(controls, 'signer.json'), contextAuthority };
|
|
56
61
|
}
|
|
57
|
-
async function
|
|
58
|
-
const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
|
|
59
|
-
if (!response.ok || result.output.status !== 'succeeded') {
|
|
60
|
-
throw Object.assign(new Error(result.output.message ?? `Scope connection blocked at ${operation}`),
|
|
61
|
-
{ code: result.output.code ?? 'ENTROROVE_SCOPE_BLOCKED', output: result.output });
|
|
62
|
-
}
|
|
63
|
-
return result.output;
|
|
64
|
-
}
|
|
65
|
-
async function connect(identity, directory, dependencies) {
|
|
66
|
-
const { binding, key, signerConfigPath, contextAuthority } = await signer(directory, identity, dependencies);
|
|
67
|
-
const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
|
|
68
|
-
repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
|
|
69
|
-
const enrollment = prepared.enrollment;
|
|
70
|
-
if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
|
|
71
|
-
|| canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
|
|
72
|
-
await remote('scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
|
|
73
|
-
signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') }, dependencies);
|
|
74
|
-
const issued = await remote('scope-runner-challenge', { flowId: identity.flowId, receiverId: binding.runnerId, attemptId: randomUUID() }, dependencies);
|
|
75
|
-
const challenge = issued.challenge;
|
|
76
|
-
if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
|
|
77
|
-
|| challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
|
|
78
|
-
|| challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
|
|
79
|
-
scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
|
|
80
|
-
}
|
|
81
|
-
const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
|
|
82
|
-
stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts,
|
|
83
|
-
...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
|
|
84
|
-
const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
|
|
85
|
-
if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
|
|
86
|
-
const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
|
|
87
|
-
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath });
|
|
88
|
-
const accepted = await remote('scope-runner-attest', { flowId: identity.flowId, attestation: observed.attestation }, dependencies);
|
|
62
|
+
async function scopeConnectionResult(identity, binding, accepted) {
|
|
89
63
|
const next = accepted.nextStep ?? { operation: 'workflow-gate', payload: { targetFiles: [...identity.targetPaths] } };
|
|
90
64
|
if (next.localOperation === 'scope-connect') return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'succeeded',
|
|
91
65
|
flowId: identity.flowId, runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
|
|
@@ -97,6 +71,85 @@ async function connect(identity, directory, dependencies) {
|
|
|
97
71
|
runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
|
|
98
72
|
nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
|
|
99
73
|
}
|
|
74
|
+
async function verifyConnectionSnapshot(input) {
|
|
75
|
+
try { return await snapshotVerify(input); }
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code !== 'ENOENT') throw error;
|
|
78
|
+
const directory = path.join(input.home, '.entrorove', 'snapshots', sha256(input.repositoryRoot).slice(0, 32), input.flowId, input.snapshotId);
|
|
79
|
+
const existing = await mutationFile14(directory, { allowMissing: true, final: 'directory' });
|
|
80
|
+
if (!existing.exists) scopeFail('ENTROROVE_SCOPE_SNAPSHOT_MISSING', 'The original scope snapshot directory is unavailable; preserve its journal before another attempt');
|
|
81
|
+
if (!(await mutationFile14(path.join(directory, 'manifest.json'), { allowMissing: true })).exists) {
|
|
82
|
+
scopeFail('ENTROROVE_SCOPE_SNAPSHOT_INTERRUPTED', 'The original scope snapshot was interrupted before its manifest; preserve the partial files and repeat this exact original input to archive its rejected attempt');
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function observeConnection(identity, location, state, original, contextAuthority, dependencies, remote) {
|
|
88
|
+
let challenge = (state.signingChallenge ?? state.challenged).challenge;
|
|
89
|
+
validateScopeRunnerChallenge(challenge, original, identity, location.binding, { allowExpired: true });
|
|
90
|
+
if (state.evidenceDescriptor) {
|
|
91
|
+
if (state.evidenceDescriptor.challengeId !== challenge.challenge) scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover evidence against its original signing challenge');
|
|
92
|
+
return challenge;
|
|
93
|
+
}
|
|
94
|
+
const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
|
|
95
|
+
stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
|
|
96
|
+
...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
|
|
97
|
+
if (!state.snapshotStarted) {
|
|
98
|
+
state.snapshotStarted = true; await saveScopeRunnerJournal(location, state);
|
|
99
|
+
try { await snapshotPlan(input); }
|
|
100
|
+
catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
|
|
101
|
+
}
|
|
102
|
+
const verified = await verifyConnectionSnapshot(input);
|
|
103
|
+
if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
|
|
104
|
+
const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
|
|
105
|
+
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
|
|
106
|
+
{ beforeSign: async () => {
|
|
107
|
+
challenge = await renewScopeRunnerChallenge(identity, location, state, original, remote);
|
|
108
|
+
return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
|
|
109
|
+
} });
|
|
110
|
+
if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
|
|
111
|
+
await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
|
|
112
|
+
return challenge;
|
|
113
|
+
}
|
|
114
|
+
async function connect(identity, directory, dependencies) {
|
|
115
|
+
const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
|
|
116
|
+
const location = { ...local, root: identity.repositoryRoot, home: dependencies.home, base: directory,
|
|
117
|
+
stateRoot: path.join(dependencies.home, '.entrorove'), directory: path.join(directory, 'scope-connection'),
|
|
118
|
+
file: path.join(directory, 'scope-connection', 'journal.json') };
|
|
119
|
+
const state = await scopeRunnerJournal(location, identity);
|
|
120
|
+
const remote = (stage, operation, input) => contextRemote(stage, operation, input, state, location, dependencies, saveScopeRunnerJournal);
|
|
121
|
+
try {
|
|
122
|
+
if (state.pending) await remote(state.pending.stage, state.pending.operation, state.pending.input);
|
|
123
|
+
const prepared = state.prepared ?? await remote('prepared', 'scope-runner-prepare', { flowId: identity.flowId,
|
|
124
|
+
attemptId: state.attemptId, runnerId: binding.runnerId, repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths });
|
|
125
|
+
const enrollment = prepared.enrollment;
|
|
126
|
+
if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
|
|
127
|
+
|| canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
|
|
128
|
+
if (!state.registered) await remote('registered', 'scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
|
|
129
|
+
signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') });
|
|
130
|
+
const issued = state.challenged ?? await remote('challenged', 'scope-runner-challenge', { flowId: identity.flowId,
|
|
131
|
+
receiverId: binding.runnerId, attemptId: state.attemptId });
|
|
132
|
+
const challenge = issued.challenge;
|
|
133
|
+
if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
|
|
134
|
+
|| challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
|
|
135
|
+
|| challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
|
|
136
|
+
scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
|
|
137
|
+
}
|
|
138
|
+
if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
|
|
139
|
+
if (!state.accepted) {
|
|
140
|
+
const signingChallenge = await observeConnection(identity, location, state, challenge, contextAuthority, dependencies, remote);
|
|
141
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge, saveScopeRunnerJournal);
|
|
142
|
+
await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
|
|
143
|
+
}
|
|
144
|
+
state.completed = true; await saveScopeRunnerJournal(location, state);
|
|
145
|
+
return scopeConnectionResult(identity, binding, state.accepted);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!state.pending && !state.evidencePending) {
|
|
148
|
+
state.rejection ??= { code: error.code, message: error.message }; await saveScopeRunnerJournal(location, state);
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
100
153
|
export async function connectScopeRunner(repositoryRoot, input, dependencies) {
|
|
101
154
|
if (!input || Object.keys(input).some(field => !['flowId', 'paths', 'importContexts'].includes(field))
|
|
102
155
|
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.flowId ?? '') || !Array.isArray(input.paths) || !input.paths.length) {
|
|
@@ -129,8 +182,12 @@ export async function runScopeConnect(repositoryRoot, input, dependencies) {
|
|
|
129
182
|
if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
|
|
130
183
|
try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
|
|
131
184
|
catch (error) {
|
|
132
|
-
if (
|
|
185
|
+
if (!error.code?.startsWith('ENTROROVE_')) throw error;
|
|
133
186
|
return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
|
|
134
|
-
|
|
187
|
+
...prerequisiteFields(error),
|
|
188
|
+
code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
|
|
189
|
+
...recoveryFailureFields(error),
|
|
190
|
+
...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
|
|
191
|
+
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
135
192
|
}
|
|
136
193
|
}
|