@entrorove/skill 0.1.73 → 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 +37 -0
- package/DESIGN-GATES.md +5 -9
- package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
- package/IDE-EXECUTION.md +21 -4
- package/REUSE-DISCIPLINE.md +3 -3
- package/SKILL.md +14 -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 +8 -4
- 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 +14 -1
- package/release-state.json +63 -50
- package/release.mjs +6 -1
- package/releases.json +30 -0
- package/remote-capability-cache.mjs +20 -0
- package/remote-client.mjs +24 -4
- package/scope-amendment-client.mjs +62 -6
- package/scope-amendment-observer.mjs +9 -3
- package/scope-amendment-remote.mjs +20 -6
- package/scope-amendment-retirement.mjs +4 -2
- package/scope-amendment-state.mjs +9 -5
- package/scope-context-client.mjs +7 -0
- package/scope-context-journal.mjs +8 -2
- package/scope-context-remote.mjs +16 -5
- package/scope-evidence-remote.mjs +16 -7
- package/scope-runner-client.mjs +34 -7
- package/scope-runner-journal.mjs +82 -5
- 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
|
@@ -21,12 +21,14 @@ async function inspectFiles(location, challenge) {
|
|
|
21
21
|
physical.add(identity);
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
export async function observeScopeAmendment(location, state) {
|
|
25
|
-
const
|
|
24
|
+
export async function observeScopeAmendment(location, state, { beforeSign } = {}) {
|
|
25
|
+
const original = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: beforeSign !== undefined });
|
|
26
|
+
let challenge = original;
|
|
26
27
|
await inspectFiles(location, challenge);
|
|
27
28
|
if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
|
|
28
29
|
const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
|
|
29
30
|
snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
|
|
31
|
+
scopeWritePaths: challenge.files.filter(file => file.access !== 'read-only').map(file => file.path).sort(),
|
|
30
32
|
contractDigest: challenge.proposalDigest, importContexts: location.contextAuthority?.record.importContexts ?? location.binding.importContexts,
|
|
31
33
|
...(location.contextAuthority ? { contextRevisionDigest: location.contextAuthority.revisionDigest,
|
|
32
34
|
contextEvidence: location.contextAuthority.record.evidence } : {}),
|
|
@@ -56,7 +58,11 @@ export async function observeScopeAmendment(location, state) {
|
|
|
56
58
|
const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
|
|
57
59
|
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
|
|
58
60
|
signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
|
|
59
|
-
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt)
|
|
61
|
+
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt),
|
|
62
|
+
...(beforeSign === undefined ? {} : { beforeSign: async () => {
|
|
63
|
+
challenge = await beforeSign();
|
|
64
|
+
return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
|
|
65
|
+
} }) });
|
|
60
66
|
const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
|
|
61
67
|
const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
|
|
62
68
|
proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
|
|
@@ -7,6 +7,8 @@ import { readInvocations } from './local-invocations.mjs';
|
|
|
7
7
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
8
8
|
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
9
|
import { retireMissingChallenge } from './scope-amendment-retirement.mjs';
|
|
10
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
11
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
10
12
|
|
|
11
13
|
const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
|
|
12
14
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
@@ -16,6 +18,8 @@ async function recover(pending, dependencies) {
|
|
|
16
18
|
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
17
19
|
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
18
20
|
const result = parseTransportResponse(response, 'original scope amendment status');
|
|
21
|
+
const prerequisite = prerequisiteError(response, result, { requestId: pending.requestId });
|
|
22
|
+
if (prerequisite) throw Object.assign(unknown(pending.requestId), { servicePrerequisite: prerequisite.servicePrerequisite });
|
|
19
23
|
if (response.status === 404 && result?.error === 'request-not-found') {
|
|
20
24
|
throw Object.assign(unknown(pending.requestId), { requestNotFound: true });
|
|
21
25
|
}
|
|
@@ -25,7 +29,8 @@ async function recover(pending, dependencies) {
|
|
|
25
29
|
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
26
30
|
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
27
31
|
return { response, result };
|
|
28
|
-
} catch (error) { throw error?.requestNotFound ? error : unknown(pending.requestId)
|
|
32
|
+
} catch (error) { throw error?.requestNotFound ? error : Object.assign(unknown(pending.requestId),
|
|
33
|
+
error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {}); }
|
|
29
34
|
}
|
|
30
35
|
async function notSent(pending, home) {
|
|
31
36
|
try {
|
|
@@ -36,10 +41,14 @@ async function notSent(pending, home) {
|
|
|
36
41
|
export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
|
|
37
42
|
const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
|
|
38
43
|
let response, result;
|
|
39
|
-
|
|
44
|
+
const existing = Boolean(state.pending);
|
|
45
|
+
if (existing) {
|
|
40
46
|
if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
|
|
41
47
|
amendmentFail('Recover the original amendment request before another operation');
|
|
42
48
|
}
|
|
49
|
+
}
|
|
50
|
+
if (existing && !await retryKnownPrerequisite(state, location, saveScopeAmendmentState)) {
|
|
51
|
+
consumeRecovery(state, 'status'); await saveScopeAmendmentState(location, state);
|
|
43
52
|
if (state.challengeRetirement) await retireMissingChallenge(state, location, dependencies, recover, notSent);
|
|
44
53
|
try { ({ response, result } = await recover(state.pending, dependencies)); }
|
|
45
54
|
catch (error) {
|
|
@@ -48,16 +57,18 @@ export async function amendmentRemote(stage, operation, payload, state, location
|
|
|
48
57
|
({ response, result } = await recover(state.pending, dependencies));
|
|
49
58
|
}
|
|
50
59
|
} else {
|
|
51
|
-
state.pending = { stage, operation: 'continue', input, requestId };
|
|
52
|
-
|
|
60
|
+
if (!existing) { state.pending = { stage, operation: 'continue', input, requestId };
|
|
61
|
+
await saveScopeAmendmentState(location, state); }
|
|
53
62
|
try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
|
|
54
63
|
catch (error) {
|
|
64
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState)) throw error;
|
|
55
65
|
if (await notSent(state.pending, dependencies.home)) {
|
|
66
|
+
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', effects: 'none', requestId };
|
|
56
67
|
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
57
68
|
throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
|
|
58
69
|
{ code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
59
70
|
}
|
|
60
|
-
throw unknown(requestId);
|
|
71
|
+
throw Object.assign(unknown(requestId), error?.servicePrerequisite ? { servicePrerequisite: error.servicePrerequisite } : {});
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
@@ -80,5 +91,8 @@ export async function scopeAmendmentStatus(flowId, dependencies) {
|
|
|
80
91
|
input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
|
|
81
92
|
if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
|
|
82
93
|
return result.output.result ?? result.output;
|
|
83
|
-
} catch
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error?.servicePrerequisite) throw error;
|
|
96
|
+
amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent');
|
|
97
|
+
}
|
|
84
98
|
}
|
|
@@ -2,6 +2,7 @@ import { canonical, sha256 } from './local-integrity.mjs';
|
|
|
2
2
|
import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
|
|
3
3
|
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
4
4
|
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
5
|
+
import { retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
5
6
|
|
|
6
7
|
export async function retireMissingChallenge(state, location, dependencies, recover, notSent) {
|
|
7
8
|
const original = state.pending;
|
|
@@ -14,14 +15,15 @@ export async function retireMissingChallenge(state, location, dependencies, reco
|
|
|
14
15
|
if (attempt && (attempt.requestId !== call.requestId || canonical(attempt.input) !== canonical(input))) {
|
|
15
16
|
amendmentFail('Challenge retirement must retain its original preparation envelope');
|
|
16
17
|
}
|
|
17
|
-
if (!attempt) {
|
|
18
|
-
attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
18
|
+
if (!attempt || await retryKnownPrerequisite(state, location, saveScopeAmendmentState, 'challengeRetirement')) {
|
|
19
|
+
if (!attempt) attempt = state.challengeRetirement = { requestId: call.requestId, input, state: 'pending' };
|
|
19
20
|
await saveScopeAmendmentState(location, state);
|
|
20
21
|
try {
|
|
21
22
|
const { result } = await invokeRemoteResult({ operation: 'scope-challenge-retire', input }, dependencies);
|
|
22
23
|
attempt.output = result.output; attempt.feedback = result.feedback; attempt.state = 'completed';
|
|
23
24
|
await saveScopeAmendmentState(location, state);
|
|
24
25
|
} catch (error) {
|
|
26
|
+
if (await retainPrerequisite(error, state, location, saveScopeAmendmentState, 'challengeRetirement')) throw error;
|
|
25
27
|
if (await notSent(attempt, dependencies.home)) {
|
|
26
28
|
attempt.state = 'not-sent'; await saveScopeAmendmentState(location, state);
|
|
27
29
|
}
|
|
@@ -10,17 +10,21 @@ import { trustedOrigin } from './remote-transport.mjs';
|
|
|
10
10
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
11
11
|
|
|
12
12
|
const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
|
|
13
|
-
const
|
|
13
|
+
const maximumControlBytes = 1_000_000;
|
|
14
|
+
// Retained scope combines original facts and verified history, not a single
|
|
15
|
+
// signing control. Keep its aggregate bound separate from key/config files.
|
|
16
|
+
const maximumRetainedScopeBytes = 8_000_000;
|
|
14
17
|
// A journal retains the challenge, S proof, original pending request and full
|
|
15
18
|
// signed response together. Each signed authority retains its own 1 MB bound.
|
|
16
19
|
const maximumJournalBytes = 8_000_000;
|
|
17
|
-
async function privateItem(file, final = 'file', maximumBytes =
|
|
20
|
+
async function privateItem(file, final = 'file', maximumBytes = maximumControlBytes) {
|
|
18
21
|
const value = await mutationFile14(file, { final });
|
|
19
22
|
if (process.getuid && value.stat.uid !== process.getuid()
|
|
20
23
|
|| process.platform !== 'win32' && value.stat.mode & 0o077
|
|
21
|
-
|| final === 'file' &&
|
|
22
|
-
fail('Scope amendment controls must be
|
|
24
|
+
|| final === 'file' && value.stat.nlink !== 1) {
|
|
25
|
+
fail('Scope amendment controls must be private single-link files owned by the current OS account');
|
|
23
26
|
}
|
|
27
|
+
if (final === 'file' && value.stat.size > maximumBytes) fail(`Scope amendment file exceeds the ${maximumBytes} byte limit`);
|
|
24
28
|
return value;
|
|
25
29
|
}
|
|
26
30
|
async function privateText(file) {
|
|
@@ -68,7 +72,7 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
|
|
|
68
72
|
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
|
|
69
73
|
|| sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
|
|
70
74
|
const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
|
|
71
|
-
await privateItem(originalStatePath);
|
|
75
|
+
await privateItem(originalStatePath, 'file', maximumRetainedScopeBytes);
|
|
72
76
|
const saved = await readMutationJson14(originalStatePath);
|
|
73
77
|
const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
|
|
74
78
|
stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
|
package/scope-context-client.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { prerequisiteFields } from './service-prerequisite.mjs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { sign } from 'node:crypto';
|
|
3
4
|
import { canonical } from './local-integrity.mjs';
|
|
@@ -9,6 +10,7 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
|
|
|
9
10
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
11
|
import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
|
|
11
12
|
import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
|
|
13
|
+
import { recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
12
14
|
import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
|
|
13
15
|
|
|
14
16
|
const schemaVersion = 'entrorove.scope-context-connection/1.0';
|
|
@@ -23,6 +25,9 @@ function proposalFor(status, input, location) {
|
|
|
23
25
|
}
|
|
24
26
|
function waiting(value, flowId) {
|
|
25
27
|
const status = value.proposal?.approval?.status;
|
|
28
|
+
if (status === 'approved') return { ...value, schemaVersion, status: 'succeeded', flowId,
|
|
29
|
+
grantsExecutionAuthority: false, claimsCompletion: false, nextOperation: 'scope-context',
|
|
30
|
+
nextInput: { action: 'apply', flowId, proposalId: value.proposal.proposalId, proposalDigest: value.proposal.proposalDigest } };
|
|
26
31
|
return { ...value, schemaVersion, status: status === 'declined' ? 'blocked' : 'needs-input', flowId,
|
|
27
32
|
grantsExecutionAuthority: false, claimsCompletion: false,
|
|
28
33
|
nextStep: value.nextStep ?? { operation: 'interaction-answer', requiresOriginalOwnerApproval: true,
|
|
@@ -118,9 +123,11 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
|
|
|
118
123
|
return await withStateLock(path.join(location.base, 'connection'), () => run(location, input, dependencies));
|
|
119
124
|
} catch (error) {
|
|
120
125
|
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
126
|
+
...prerequisiteFields(error),
|
|
121
127
|
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
|
|
122
128
|
message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
|
|
123
129
|
...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
130
|
+
...recoveryFailureFields(error),
|
|
124
131
|
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
|
|
125
132
|
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
126
133
|
}
|
|
@@ -5,6 +5,7 @@ import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files
|
|
|
5
5
|
import { contextFail } from './scope-context-wire.mjs';
|
|
6
6
|
import { readContextJournal, saveContextJournal } from './scope-context-state.mjs';
|
|
7
7
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
8
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
8
9
|
|
|
9
10
|
export async function archiveContextAttempt(location, state) {
|
|
10
11
|
if (!state.attemptId) return;
|
|
@@ -20,22 +21,27 @@ export async function contextJournal(location, input) {
|
|
|
20
21
|
let state = await readContextJournal(location, input);
|
|
21
22
|
if (!state.previous && reusableSnapshot(state, input)) {
|
|
22
23
|
await archiveContextAttempt(location, state);
|
|
24
|
+
consumeRecovery(state, 'attempt', state.signingAttemptId ?? state.attemptId);
|
|
23
25
|
for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
|
|
24
26
|
'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
|
|
25
27
|
await saveContextJournal(location, state);
|
|
26
28
|
} else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
|
|
27
29
|
|| input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
|
|
28
|
-
|
|
30
|
+
const previous = state.previous ?? state;
|
|
31
|
+
await archiveContextAttempt(location, previous);
|
|
32
|
+
const recovery = previous.completed ? undefined : consumeRecovery(previous, 'attempt', previous.attemptId);
|
|
33
|
+
state = { ...(recovery ? { recovery } : {}) };
|
|
29
34
|
}
|
|
30
35
|
if (!state.attemptId) {
|
|
31
36
|
state = { schemaVersion: 'entrorove.scope-context-journal/1.0', localBindingDigest: location.localBindingDigest,
|
|
32
|
-
input, attemptId: randomUUID() }; await saveContextJournal(location, state);
|
|
37
|
+
input, attemptId: randomUUID(), ...(state.recovery ? { recovery: state.recovery } : {}) }; await saveContextJournal(location, state);
|
|
33
38
|
}
|
|
34
39
|
return state;
|
|
35
40
|
}
|
|
36
41
|
export async function renewContextChallenge(location, state, proposal, dependencies) {
|
|
37
42
|
if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
|
|
38
43
|
await archiveContextAttempt(location, state);
|
|
44
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
39
45
|
delete state.signingChallenge;
|
|
40
46
|
state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
|
|
41
47
|
const output = await contextRemote('signingChallenge', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
|
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 {
|
|
@@ -32,20 +37,26 @@ async function notSent(pending, home) {
|
|
|
32
37
|
}
|
|
33
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
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);
|
|
@@ -4,6 +4,7 @@ import { readScopeEvidence, retainScopeEvidence } from './scope-evidence-local.m
|
|
|
4
4
|
import { describeScopeEvidence, evidenceBytes, evidenceFail, evidenceUpload, evidenceReference } from './scope-evidence-wire.mjs';
|
|
5
5
|
import { requestTransport, putScopeEvidence, parseTransportResponse } from './remote-transport.mjs';
|
|
6
6
|
import { ideClientHeaders } from './ide-presentation.mjs';
|
|
7
|
+
import { prerequisiteError, retainPrerequisite, retryKnownPrerequisite } from './service-prerequisite.mjs';
|
|
7
8
|
|
|
8
9
|
const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
|
|
9
10
|
{ code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
|
|
@@ -24,10 +25,15 @@ function resultReference(value, upload, descriptor, location) {
|
|
|
24
25
|
if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
|
|
25
26
|
return reference;
|
|
26
27
|
}
|
|
27
|
-
async function acceptResult(response, upload, state, location, save) {
|
|
28
|
+
async function acceptResult(response, upload, state, location, save, sentPut = false) {
|
|
28
29
|
let value;
|
|
29
30
|
try { value = parseTransportResponse(response, 'scope evidence upload'); }
|
|
30
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
|
+
}
|
|
31
37
|
const reference = resultReference(value, upload, state.evidenceDescriptor, location);
|
|
32
38
|
if (reference) {
|
|
33
39
|
state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
|
|
@@ -51,22 +57,25 @@ export async function ensureScopeEvidence(state, location, dependencies, challen
|
|
|
51
57
|
const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
|
|
52
58
|
const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
|
|
53
59
|
const origin = dependencies.apiOrigin();
|
|
54
|
-
|
|
60
|
+
const existing = Boolean(state.evidencePending);
|
|
61
|
+
if (existing) {
|
|
55
62
|
if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
|
|
56
63
|
|| state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
|
|
64
|
+
}
|
|
65
|
+
if (existing && !await retryKnownPrerequisite(state, location, save, 'evidencePending')) {
|
|
57
66
|
let response;
|
|
58
67
|
try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
|
|
59
68
|
catch { throw unknown(upload.uploadId); }
|
|
60
69
|
return acceptResult(response, upload, state, location, save);
|
|
61
70
|
}
|
|
62
|
-
state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
|
|
63
|
-
|
|
64
|
-
let response;
|
|
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();
|
|
65
74
|
try {
|
|
66
|
-
if (
|
|
75
|
+
if (!sentPut) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
|
|
67
76
|
else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
|
|
68
77
|
} catch { throw unknown(upload.uploadId); }
|
|
69
|
-
return acceptResult(response, upload, state, location, save);
|
|
78
|
+
return acceptResult(response, upload, state, location, save, sentPut);
|
|
70
79
|
}
|
|
71
80
|
export async function validateAppliedEvidence(record, state, location, challenge, kind) {
|
|
72
81
|
const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
|
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,10 +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';
|
|
10
|
+
import { recoveryFailureFields } from './local-recovery-budget.mjs';
|
|
9
11
|
import { contextRemote } from './scope-context-remote.mjs';
|
|
10
|
-
import { scopeRunnerJournal, saveScopeRunnerJournal } from './scope-runner-journal.mjs';
|
|
12
|
+
import { scopeRunnerJournal, saveScopeRunnerJournal, renewScopeRunnerChallenge, validateScopeRunnerChallenge } from './scope-runner-journal.mjs';
|
|
11
13
|
import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
|
|
12
14
|
import { boundScopeContexts } from './scope-context-state.mjs';
|
|
15
|
+
import { mutationFile14 } from './local-mutation-files.mjs';
|
|
13
16
|
|
|
14
17
|
async function privateDirectory(directory) {
|
|
15
18
|
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
@@ -68,8 +71,26 @@ async function scopeConnectionResult(identity, binding, accepted) {
|
|
|
68
71
|
runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
|
|
69
72
|
nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
|
|
70
73
|
}
|
|
71
|
-
async function
|
|
72
|
-
|
|
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
|
+
}
|
|
73
94
|
const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
|
|
74
95
|
stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
|
|
75
96
|
...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
|
|
@@ -78,13 +99,17 @@ async function observeConnection(identity, location, state, challenge, contextAu
|
|
|
78
99
|
try { await snapshotPlan(input); }
|
|
79
100
|
catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
|
|
80
101
|
}
|
|
81
|
-
const verified = await
|
|
102
|
+
const verified = await verifyConnectionSnapshot(input);
|
|
82
103
|
if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
|
|
83
104
|
const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
|
|
84
105
|
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
|
|
85
|
-
{
|
|
106
|
+
{ beforeSign: async () => {
|
|
107
|
+
challenge = await renewScopeRunnerChallenge(identity, location, state, original, remote);
|
|
108
|
+
return { challenge: challenge.challenge, maximumExpiresAt: Date.parse(challenge.expiresAt) };
|
|
109
|
+
} });
|
|
86
110
|
if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
|
|
87
111
|
await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
|
|
112
|
+
return challenge;
|
|
88
113
|
}
|
|
89
114
|
async function connect(identity, directory, dependencies) {
|
|
90
115
|
const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
|
|
@@ -112,8 +137,8 @@ async function connect(identity, directory, dependencies) {
|
|
|
112
137
|
}
|
|
113
138
|
if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
|
|
114
139
|
if (!state.accepted) {
|
|
115
|
-
await observeConnection(identity, location, state, challenge, contextAuthority, dependencies);
|
|
116
|
-
const attestationRef = await ensureScopeEvidence(state, location, dependencies,
|
|
140
|
+
const signingChallenge = await observeConnection(identity, location, state, challenge, contextAuthority, dependencies, remote);
|
|
141
|
+
const attestationRef = await ensureScopeEvidence(state, location, dependencies, signingChallenge, saveScopeRunnerJournal);
|
|
117
142
|
await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
|
|
118
143
|
}
|
|
119
144
|
state.completed = true; await saveScopeRunnerJournal(location, state);
|
|
@@ -159,7 +184,9 @@ export async function runScopeConnect(repositoryRoot, input, dependencies) {
|
|
|
159
184
|
catch (error) {
|
|
160
185
|
if (!error.code?.startsWith('ENTROROVE_')) throw error;
|
|
161
186
|
return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
|
|
187
|
+
...prerequisiteFields(error),
|
|
162
188
|
code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
|
|
189
|
+
...recoveryFailureFields(error),
|
|
163
190
|
...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
|
|
164
191
|
...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
165
192
|
}
|
package/scope-runner-journal.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
4
|
import { readMutationJson14, atomicMutationJson14, mutationFile14 } from './local-mutation-files.mjs';
|
|
5
5
|
import { evidenceFail } from './scope-evidence-wire.mjs';
|
|
6
|
+
import { scopeFail } from './local-scope-files.mjs';
|
|
7
|
+
import { consumeRecovery } from './local-recovery-budget.mjs';
|
|
6
8
|
|
|
7
9
|
export async function saveScopeRunnerJournal(location, state) {
|
|
8
10
|
if (Buffer.byteLength(canonical(state)) + 1 > 8_000_000) evidenceFail('Scope connection journal exceeds its unchanged bounded control limit');
|
|
@@ -18,11 +20,19 @@ export async function scopeRunnerJournal(location, identity) {
|
|
|
18
20
|
evidenceFail('Scope connection journal replaced its original task, repository or complete input');
|
|
19
21
|
}
|
|
20
22
|
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
-
if (state &&
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
if (state && reusableExpiredScopeSnapshot(state, identity)) {
|
|
24
|
+
const archivedDigest = await archiveScopeRunnerAttempt(location, state);
|
|
25
|
+
consumeRecovery(state, 'attempt', state.attemptId);
|
|
26
|
+
state.signingRecovery = { reason: 'known-local-signature-expiry', archivedDigest, snapshotId: state.snapshotId,
|
|
27
|
+
requiresSnapshotVerification: true, grantsExecutionAuthority: false, recoveredAt: new Date().toISOString() };
|
|
28
|
+
delete state.rejection;
|
|
29
|
+
await saveScopeRunnerJournal(location, state);
|
|
30
|
+
} else if (state && !state.pending && !state.evidencePending && (state.completed || state.rejection)) {
|
|
31
|
+
await archiveScopeRunnerAttempt(location, state);
|
|
32
|
+
const recovery = state.completed ? undefined : consumeRecovery(state, 'attempt', state.attemptId);
|
|
33
|
+
state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID(),
|
|
34
|
+
...(recovery ? { recovery } : {}) };
|
|
35
|
+
await saveScopeRunnerJournal(location, state);
|
|
26
36
|
}
|
|
27
37
|
if (!state) {
|
|
28
38
|
state = { schemaVersion: 'entrorove.scope-connection-journal/1.0', identity, attemptId: randomUUID() };
|
|
@@ -30,3 +40,70 @@ export async function scopeRunnerJournal(location, identity) {
|
|
|
30
40
|
}
|
|
31
41
|
return state;
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
export async function archiveScopeRunnerAttempt(location, state) {
|
|
45
|
+
const checksum = sha256(canonical(state));
|
|
46
|
+
const archive = path.join(location.directory, `${state.attemptId}-${checksum}.json`);
|
|
47
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) evidenceFail('Scope connection history changed'); }
|
|
48
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
49
|
+
return checksum;
|
|
50
|
+
}
|
|
51
|
+
export function validateScopeRunnerChallenge(value, original, identity, binding, { allowExpired = false } = {}) {
|
|
52
|
+
const immutable = challenge => Object.fromEntries(Object.entries(challenge ?? {}).filter(([field]) => !['challenge', 'expiresAt'].includes(field)));
|
|
53
|
+
const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/, hash = /^[a-f0-9]{64}$/;
|
|
54
|
+
if (!value || !original || canonical(immutable(value)) !== canonical(immutable(original))
|
|
55
|
+
|| value.flowId !== identity.flowId || value.runnerId !== binding.runnerId || value.receiverId !== binding.runnerId
|
|
56
|
+
|| value.repositoryId !== binding.repositoryId || canonical(value.targetPaths) !== canonical(identity.targetPaths)
|
|
57
|
+
|| ['stepId', 'ownerId', 'challenge'].some(field => !id.test(value[field] ?? ''))
|
|
58
|
+
|| ['taskDigest', 'chainDigest'].some(field => !hash.test(value[field] ?? ''))
|
|
59
|
+
|| !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0
|
|
60
|
+
|| !value.task || typeof value.task !== 'object' || Array.isArray(value.task)) {
|
|
61
|
+
scopeFail('ENTROROVE_SCOPE_BINDING', 'Fresh scope challenge replaced its original task, signer, step, paths or revision');
|
|
62
|
+
}
|
|
63
|
+
const expiresAt = Date.parse(value.expiresAt);
|
|
64
|
+
if (!Number.isFinite(expiresAt) || !allowExpired && (expiresAt <= Date.now() || expiresAt > Date.now() + 90_000)) {
|
|
65
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'Actual scope signature requires its current bounded original challenge');
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function noScopeSubmission(state) {
|
|
70
|
+
return !['pending', 'evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'evidenceSignature',
|
|
71
|
+
'attestation', 'attestationRef', 'evidenceResult', 'accepted'].some(field => Object.hasOwn(state, field));
|
|
72
|
+
}
|
|
73
|
+
function reusableExpiredScopeSnapshot(state, identity) {
|
|
74
|
+
if (!noScopeSubmission(state) || state.completed || state.snapshotStarted !== true
|
|
75
|
+
|| typeof state.snapshotId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(state.snapshotId)
|
|
76
|
+
|| state.rejection?.code !== 'ENTROROVE_SCOPE_ATTESTATION_BINDING'
|
|
77
|
+
|| state.rejection.message !== 'candidate challenge expired before its actual scope signature'
|
|
78
|
+
|| state.rejection.requestId || state.rejection.output || state.registered?.registered !== true) return false;
|
|
79
|
+
const enrollment = state.prepared?.enrollment, original = state.challenged?.challenge;
|
|
80
|
+
if (!enrollment || !original || enrollment.flowId !== identity.flowId || state.registered.runnerId !== enrollment.runnerId
|
|
81
|
+
|| state.registered.repositoryId !== enrollment.repositoryId
|
|
82
|
+
|| canonical(state.registered.targetPaths) !== canonical(identity.targetPaths)
|
|
83
|
+
|| ['ownerId', 'taskDigest', 'chainDigest', 'runnerId', 'repositoryId'].some(field => enrollment[field] !== original[field])
|
|
84
|
+
|| canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) return false;
|
|
85
|
+
try { validateScopeRunnerChallenge((state.signingChallenge ?? state.challenged).challenge, original, identity, enrollment, { allowExpired: true }); }
|
|
86
|
+
catch { return false; }
|
|
87
|
+
return Date.parse((state.signingChallenge ?? state.challenged).challenge.expiresAt) <= Date.now();
|
|
88
|
+
}
|
|
89
|
+
export async function renewScopeRunnerChallenge(identity, location, state, original, remote) {
|
|
90
|
+
if (!['evidencePending', 'evidenceDescriptor', 'evidencePrepared', 'attestationRef', 'attestation', 'accepted']
|
|
91
|
+
.every(field => !Object.hasOwn(state, field)) || canonical(state.identity) !== canonical(identity)) {
|
|
92
|
+
scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover the original signed evidence before another scope signing challenge');
|
|
93
|
+
}
|
|
94
|
+
if (state.pending && (state.pending.stage !== 'signingChallenge' || !state.signingAttemptId)) {
|
|
95
|
+
scopeFail('ENTROROVE_SCOPE_BINDING', 'Recover only the original pending request before challenge renewal');
|
|
96
|
+
}
|
|
97
|
+
if (!state.pending && state.signingChallenge && Date.parse(state.signingChallenge.challenge?.expiresAt) > Date.now()) {
|
|
98
|
+
return validateScopeRunnerChallenge(state.signingChallenge.challenge, original, identity, location.binding);
|
|
99
|
+
}
|
|
100
|
+
if (!state.pending && (!state.signingAttemptId || state.signingChallenge)) {
|
|
101
|
+
await archiveScopeRunnerAttempt(location, state);
|
|
102
|
+
consumeRecovery(state, 'signing', state.signingAttemptId ?? state.attemptId);
|
|
103
|
+
delete state.signingChallenge; state.signingAttemptId = randomUUID();
|
|
104
|
+
await saveScopeRunnerJournal(location, state);
|
|
105
|
+
}
|
|
106
|
+
const issued = await remote('signingChallenge', 'scope-runner-challenge', { flowId: identity.flowId,
|
|
107
|
+
receiverId: location.binding.runnerId, attemptId: state.signingAttemptId });
|
|
108
|
+
return validateScopeRunnerChallenge(issued.challenge, original, identity, location.binding);
|
|
109
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
type File = { path: string; bytes: number; lines: number }
|
|
2
|
+
export function projectScopeWriteFacts<T extends { files: File[]; recentChangedLines: number }>(
|
|
3
|
+
facts: T, signedFacts: Record<string, any>, roles: Array<{ path: string; access: 'write' | 'read-only' | 'create' }>
|
|
4
|
+
): T
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const version = 'entrorove.scope-write-observation/1.0';
|
|
2
|
+
const fields = ['schemaVersion', 'paths', 'fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'git', 'estimatedChangedLines'];
|
|
3
|
+
function fail() {
|
|
4
|
+
throw Object.assign(new Error('Write observations require the exact approved roles and independently signed source counts'),
|
|
5
|
+
{ code: 'ENTROROVE_SCOPE_WRITE_OBSERVATION' });
|
|
6
|
+
}
|
|
7
|
+
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
|
|
8
|
+
export function scopeWriteFiles(files, requested) {
|
|
9
|
+
if (!Array.isArray(requested) || !requested.length || requested.length > files.length
|
|
10
|
+
|| requested.some(value => typeof value !== 'string') || new Set(requested).size !== requested.length) fail();
|
|
11
|
+
const selected = files.filter(file => requested.includes(file.path));
|
|
12
|
+
if (selected.length !== requested.length) fail();
|
|
13
|
+
return selected;
|
|
14
|
+
}
|
|
15
|
+
export function scopeEstimatedChanges(files, observation, knownFiles) {
|
|
16
|
+
const git = observation.facts;
|
|
17
|
+
return git.available && git.head ? observation.currentDiffPresent ? git.currentChangedLines : git.recentChangedLines
|
|
18
|
+
: files.reduce((total, file) => total + file.lines + (file.exists === false
|
|
19
|
+
? knownFiles.find(previous => previous.path === file.path)?.lines ?? 0 : 0), 0);
|
|
20
|
+
}
|
|
21
|
+
export function scopeWriteObservation(files, observation, knownFiles) {
|
|
22
|
+
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
|
23
|
+
return { schemaVersion: version, paths: files.map(file => file.path).sort(), fileCount: files.length, bytes,
|
|
24
|
+
sourceLines: files.reduce((total, file) => total + file.lines, 0), tokenEstimate: Math.ceil(bytes / 4),
|
|
25
|
+
git: observation.facts, estimatedChangedLines: scopeEstimatedChanges(files, observation, knownFiles) };
|
|
26
|
+
}
|
|
27
|
+
// Only call after verifying the original S receipt and its exact approved role set.
|
|
28
|
+
// Older signed receipts retain their conservative full-scope change estimate.
|
|
29
|
+
export function projectScopeWriteFacts(facts, signedFacts, roles) {
|
|
30
|
+
const files = scopeWriteFiles(facts.files, roles.filter(file => file.access !== 'read-only').map(file => file.path));
|
|
31
|
+
const observed = signedFacts.writeScope;
|
|
32
|
+
if (observed === undefined) return { ...facts, files };
|
|
33
|
+
if (!observed || typeof observed !== 'object' || !same(Object.keys(observed).sort(), [...fields].sort())
|
|
34
|
+
|| observed.schemaVersion !== version || !same(observed.paths, files.map(file => file.path).sort())
|
|
35
|
+
|| !['fileCount', 'bytes', 'sourceLines', 'tokenEstimate', 'estimatedChangedLines']
|
|
36
|
+
.every(field => Number.isSafeInteger(observed[field]) && observed[field] >= 0)
|
|
37
|
+
|| observed.fileCount !== files.length || observed.bytes !== files.reduce((sum, file) => sum + file.bytes, 0)
|
|
38
|
+
|| observed.sourceLines !== files.reduce((sum, file) => sum + file.lines, 0)
|
|
39
|
+
|| observed.tokenEstimate !== Math.ceil(observed.bytes / 4)
|
|
40
|
+
|| !observed.git || !signedFacts.git
|
|
41
|
+
|| ['available', 'head', 'baseline'].some(field => observed.git[field] !== signedFacts.git[field])) fail();
|
|
42
|
+
return { ...facts, files, recentChangedLines: observed.estimatedChangedLines };
|
|
43
|
+
}
|