@entrorove/skill 0.1.70 → 0.1.73

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/IDE-EXECUTION.md +4 -0
  3. package/SKILL.md +3 -2
  4. package/local-budget-reads.mjs +7 -2
  5. package/local-budget.mjs +3 -2
  6. package/local-probe.mjs +16 -7
  7. package/local-scope-build-data.mjs +73 -0
  8. package/local-scope-build-facts.mjs +33 -0
  9. package/local-scope-build-io.mjs +57 -0
  10. package/local-scope-build-read.mjs +22 -0
  11. package/local-scope-build-readset.mjs +49 -0
  12. package/local-scope-files.mjs +20 -7
  13. package/local-scope-repository.mjs +47 -37
  14. package/local-scope-signature.mjs +6 -4
  15. package/local-scope-state.mjs +7 -5
  16. package/local-scope-uni-build.mjs +69 -119
  17. package/local-scope-uni-conditional.mjs +8 -6
  18. package/local-scope-uni-directives.mjs +5 -5
  19. package/local-scope-uni-expressions.mjs +10 -5
  20. package/local-scope-workspaces.mjs +3 -3
  21. package/manifest.json +1 -1
  22. package/package.json +12 -2
  23. package/release-source-groups.mjs +46 -0
  24. package/release-state.json +40 -30
  25. package/release.mjs +6 -40
  26. package/releases.json +18 -0
  27. package/remote-client.mjs +1 -1
  28. package/remote-transport.mjs +14 -0
  29. package/scope-amendment-client.mjs +31 -19
  30. package/scope-amendment-reference.mjs +22 -4
  31. package/scope-amendment-state.mjs +1 -1
  32. package/scope-context-client.mjs +17 -8
  33. package/scope-context-journal.mjs +5 -4
  34. package/scope-context-observer.mjs +3 -2
  35. package/scope-context-remote.mjs +5 -5
  36. package/scope-context-state.mjs +4 -1
  37. package/scope-context-wire.mjs +9 -1
  38. package/scope-evidence-local.mjs +52 -0
  39. package/scope-evidence-remote.mjs +78 -0
  40. package/scope-evidence-wire.mjs +120 -0
  41. package/scope-runner-client.mjs +65 -35
  42. package/scope-runner-journal.mjs +32 -0
@@ -9,6 +9,7 @@ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
9
9
  import { contextRemote } from './scope-context-remote.mjs';
10
10
  import { inspectContextEvidence, observeScopeContext, prepareScopeContext } from './scope-context-observer.mjs';
11
11
  import { contextJournal, renewContextChallenge } from './scope-context-journal.mjs';
12
+ import { saveLocalScopeEvidence, ensureScopeEvidence, validateAppliedEvidence } from './scope-evidence-remote.mjs';
12
13
 
13
14
  const schemaVersion = 'entrorove.scope-context-connection/1.0';
14
15
  function proposalFor(status, input, location) {
@@ -56,8 +57,10 @@ async function review(location, state, dependencies) {
56
57
  async function applied(location, state) {
57
58
  const effective = state.applied.contextAuthority;
58
59
  const challenge = contextChallenge((state.signingChallenge ?? state.challenged).challenge, location, state.proposal, { allowExpired: true });
60
+ const compact = effective?.record?.schemaVersion === 'entrorove.scope-context-authority/2.0';
61
+ if (compact) await validateAppliedEvidence(effective.record, state, location, challenge, 'scope-context');
59
62
  if (state.applied.grantsExecutionAuthority !== false || !effective?.record
60
- || canonical(effective.record.attestation) !== canonical(state.attestation)
63
+ || !compact && canonical(effective.record.attestation) !== canonical(state.attestation)
61
64
  || ['proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => effective.record[field] !== challenge[field])
62
65
  || canonical(effective.record.importContexts) !== canonical(challenge.importContexts)
63
66
  || canonical(effective.record.evidence) !== canonical(challenge.evidence)) contextFail('Applied context authority differs from this exact actual candidate');
@@ -76,14 +79,20 @@ async function apply(location, state, dependencies) {
76
79
  state.proposal = proposal; await saveContextJournal(location, state);
77
80
  if (!state.challenged) await contextRemote('challenged', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
78
81
  proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attemptId: state.attemptId }, state, location, dependencies);
79
- if (!state.attestation) {
82
+ if (!state.evidenceDescriptor) {
83
+ let attestation = state.attestation;
84
+ if (!attestation) {
80
85
  const prepared = await prepareScopeContext(location, state, proposal);
81
- state.attestation = await observeScopeContext(location, state, proposal, prepared,
86
+ attestation = await observeScopeContext(location, state, proposal, prepared,
82
87
  () => renewContextChallenge(location, state, proposal, dependencies));
83
- await saveContextJournal(location, state);
88
+ }
89
+ await saveLocalScopeEvidence(attestation, 'scope-context', (state.signingChallenge ?? state.challenged).challenge,
90
+ state, location, saveContextJournal);
84
91
  }
92
+ const attestationRef = await ensureScopeEvidence(state, location, dependencies,
93
+ (state.signingChallenge ?? state.challenged).challenge, saveContextJournal);
85
94
  await contextRemote('applied', 'scope-runner-context-apply', { flowId: location.binding.flowId,
86
- proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attestation: state.attestation }, state, location, dependencies);
95
+ proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attestationRef }, state, location, dependencies);
87
96
  return applied(location, state);
88
97
  }
89
98
  async function run(location, input, dependencies) {
@@ -96,7 +105,7 @@ async function run(location, input, dependencies) {
96
105
  state.completed = true; await saveContextJournal(location, state);
97
106
  return { schemaVersion, ...status, status: 'succeeded', flowId: input.flowId, grantsExecutionAuthority: false, claimsCompletion: false };
98
107
  } catch (error) {
99
- if (!state.pending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
108
+ if (!state.pending && !state.evidencePending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
100
109
  throw error;
101
110
  }
102
111
  }
@@ -111,8 +120,8 @@ export async function runScopeContext(repositoryRoot, input, dependencies) {
111
120
  return { schemaVersion, status: 'blocked', flowId: input?.flowId,
112
121
  code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
113
122
  message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
114
- ...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.output ? { output: error.output } : {}),
123
+ ...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.uploadId ? { uploadId: error.uploadId } : {}), ...(error?.output ? { output: error.output } : {}),
115
124
  ...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
116
- requestId: error.requestId, reuseOriginalInput: true, repeatPost: false } } : {}) };
125
+ ...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
117
126
  }
118
127
  }
@@ -13,16 +13,17 @@ export async function archiveContextAttempt(location, state) {
13
13
  catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(file, state, { privateFrom: location.stateRoot }); }
14
14
  }
15
15
  function reusableSnapshot(state, input) {
16
- return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.completed && !state.applied
16
+ return input.action === 'apply' && state.snapshot && state.challenged && !state.pending && !state.evidencePending && !state.completed && !state.applied
17
17
  && state.rejection && !state.rejection.requestId && !state.rejection.output;
18
18
  }
19
19
  export async function contextJournal(location, input) {
20
20
  let state = await readContextJournal(location, input);
21
21
  if (!state.previous && reusableSnapshot(state, input)) {
22
22
  await archiveContextAttempt(location, state);
23
- for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId']) delete state[field];
23
+ for (const field of ['rejection', 'checked', 'attestation', 'signingChallenge', 'signingAttemptId',
24
+ 'evidenceDescriptor', 'evidenceSignature', 'evidencePrepared', 'attestationRef', 'evidenceResult']) delete state[field];
24
25
  await saveContextJournal(location, state);
25
- } else if (state.previous || !state.pending && !state.applied && (state.rejection
26
+ } else if (state.previous || !state.pending && !state.evidencePending && !state.applied && (state.rejection
26
27
  || input.action === 'status' && state.completed || input.action === 'apply' && state.completed)) {
27
28
  await archiveContextAttempt(location, state.previous ?? state); state = {};
28
29
  }
@@ -33,7 +34,7 @@ export async function contextJournal(location, input) {
33
34
  return state;
34
35
  }
35
36
  export async function renewContextChallenge(location, state, proposal, dependencies) {
36
- if (state.pending) contextFail('Recover the original pending request before any challenge renewal');
37
+ if (state.pending || state.evidencePending) contextFail('Recover the original pending request before any challenge renewal');
37
38
  await archiveContextAttempt(location, state);
38
39
  delete state.signingChallenge;
39
40
  state.signingAttemptId = randomUUID(); await saveContextJournal(location, state);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID, sign } from 'node:crypto';
2
2
  import { canonical } from './local-integrity.mjs';
3
- import { scopeRead, scopeTarget } from './local-scope-files.mjs';
3
+ import { scopeBuildSourceRead, scopeTarget } from './local-scope-files.mjs';
4
4
  import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
5
5
  import { collectScopeFacts } from './local-scope-repository.mjs';
6
6
  import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
@@ -20,7 +20,8 @@ export async function inspectContextEvidence(location, contexts, evidencePaths)
20
20
  for (const relative of [...paths].sort()) {
21
21
  const target = await scopeTarget(location.root, relative);
22
22
  if (!target.stat.isFile() || target.stat.size > 1_048_576) contextFail('Build evidence must be an actual ordinary bounded repository file');
23
- const read = await scopeRead(location.root, relative, { repositoryRoot: location.root });
23
+ const read = await scopeBuildSourceRead(location.root, relative, { repositoryRoot: location.root });
24
+ if (read.bytes > 1_048_576) contextFail('Build evidence exceeds its actual 1 MiB inspection bound');
24
25
  evidence.push({ path: relative, sha256: read.sha256 });
25
26
  }
26
27
  return evidence;
@@ -30,18 +30,18 @@ async function notSent(pending, home) {
30
30
  return !rows.length || rows.at(-1).event === 'not-sent';
31
31
  } catch (error) { return error.code === 'ENOENT'; }
32
32
  }
33
- export async function contextRemote(stage, operation, input, state, location, dependencies) {
33
+ export async function contextRemote(stage, operation, input, state, location, dependencies, save = saveContextJournal) {
34
34
  const requestId = buildInvocation(operation, input).requestId; let response, result;
35
35
  if (state.pending) {
36
36
  if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
37
37
  || canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
38
38
  ({ response, result } = await recover(state.pending, dependencies));
39
39
  } else {
40
- state.pending = { stage, operation, input, requestId }; await saveContextJournal(location, state);
40
+ state.pending = { stage, operation, input, requestId }; await save(location, state);
41
41
  try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
42
42
  catch (error) {
43
43
  if (await notSent(state.pending, dependencies.home)) {
44
- delete state.pending; await saveContextJournal(location, state);
44
+ delete state.pending; await save(location, state);
45
45
  throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
46
46
  { code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
47
47
  }
@@ -51,10 +51,10 @@ export async function contextRemote(stage, operation, input, state, location, de
51
51
  if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
52
52
  if (result.feedback) state.feedback = result.feedback;
53
53
  if (!response.ok || !['succeeded', 'needs-input'].includes(result.output.status)) {
54
- state.rejection = { requestId, output: result.output }; delete state.pending; await saveContextJournal(location, state);
54
+ state.rejection = { requestId, output: result.output }; delete state.pending; await save(location, state);
55
55
  throw Object.assign(new Error('Original context request was rejected; preserve its exact evidence and history'),
56
56
  { code: result.output.code ?? 'ENTROROVE_SCOPE_CONTEXT_REJECTED', requestId, output: result.output });
57
57
  }
58
- state[stage] = result.output.result ?? result.output; delete state.pending; await saveContextJournal(location, state);
58
+ state[stage] = result.output.result ?? result.output; delete state.pending; await save(location, state);
59
59
  return state[stage];
60
60
  }
@@ -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
  }
@@ -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 record = scopeRevisionAuthority(value, 'entrorove.scope-context-authority/1.0');
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,78 @@
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
+
8
+ const unknown = uploadId => Object.assign(new Error('Scope evidence upload outcome is unknown; recover the original reservation with GET only'),
9
+ { code: 'ENTROROVE_SCOPE_EVIDENCE_UNKNOWN', outcomeUnknown: true, uploadId });
10
+ export async function saveLocalScopeEvidence(value, kind, challenge, state, location, save) {
11
+ const described = describeScopeEvidence(kind, value, challenge, location);
12
+ await retainScopeEvidence(location, described.descriptor, value);
13
+ state.evidenceDescriptor = described.descriptor; state.evidenceSignature = described.signature;
14
+ state.attestationDigest = described.descriptor.sha256;
15
+ delete state.attestation;
16
+ await save(location, state);
17
+ }
18
+ function resultReference(value, upload, descriptor, location) {
19
+ if (value?.schemaVersion !== 'entrorove.scope-evidence-upload-status/1.0' || value.uploadId !== upload.uploadId
20
+ || value.grantsExecutionAuthority !== false || value.expiresAt !== upload.expiresAt
21
+ || !['reserved', 'receiving', 'stored', 'rejected'].includes(value.status)) throw unknown(upload.uploadId);
22
+ if (value.status !== 'stored') return null;
23
+ const reference = evidenceReference(value.evidenceReference, descriptor, location);
24
+ if (['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== upload[field])) evidenceFail('Stored scope evidence replaced its original account or task');
25
+ return reference;
26
+ }
27
+ async function acceptResult(response, upload, state, location, save) {
28
+ let value;
29
+ try { value = parseTransportResponse(response, 'scope evidence upload'); }
30
+ catch { throw unknown(upload.uploadId); }
31
+ const reference = resultReference(value, upload, state.evidenceDescriptor, location);
32
+ if (reference) {
33
+ state.attestationRef = reference; state.evidenceResult = value; delete state.evidencePending;
34
+ await save(location, state); return reference;
35
+ }
36
+ if (value.status === 'rejected' && /^ENTROROVE_EVIDENCE_[A-Z_]+$/.test(value.code ?? '')) {
37
+ state.evidenceResult = value; state.rejection = { uploadId: upload.uploadId, output: value };
38
+ delete state.evidencePending; await save(location, state);
39
+ throw Object.assign(new Error('The original evidence reservation was rejected; preserve it before another signed attempt'),
40
+ { code: value.code, uploadId: upload.uploadId, output: value });
41
+ }
42
+ throw unknown(upload.uploadId);
43
+ }
44
+ export async function ensureScopeEvidence(state, location, dependencies, challenge, save) {
45
+ const descriptor = state.evidenceDescriptor;
46
+ if (!descriptor || !state.evidenceSignature) evidenceFail('Original signed evidence descriptor is unavailable');
47
+ const value = await readScopeEvidence(location, descriptor);
48
+ if (state.attestationRef) return evidenceReference(state.attestationRef, descriptor, location);
49
+ if (!state.evidencePrepared) await contextRemote('evidencePrepared', 'scope-evidence-prepare', {
50
+ flowId: descriptor.flowId, descriptor, signature: state.evidenceSignature }, state, location, dependencies, save);
51
+ const upload = evidenceUpload(state.evidencePrepared.upload, descriptor, location, challenge);
52
+ const token = await dependencies.readCredential(dependencies.home), headers = { authorization: `Bearer ${token}`, ...ideClientHeaders() };
53
+ const origin = dependencies.apiOrigin();
54
+ if (state.evidencePending) {
55
+ if (state.evidencePending.uploadId !== upload.uploadId || state.evidencePending.sha256 !== descriptor.sha256
56
+ || state.evidencePending.canonicalBytes !== descriptor.canonicalBytes) evidenceFail('Recover the exact original evidence reservation first');
57
+ let response;
58
+ try { response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers }); }
59
+ catch { throw unknown(upload.uploadId); }
60
+ return acceptResult(response, upload, state, location, save);
61
+ }
62
+ state.evidencePending = { uploadId: upload.uploadId, sha256: descriptor.sha256, canonicalBytes: descriptor.canonicalBytes };
63
+ await save(location, state);
64
+ let response;
65
+ try {
66
+ if (Date.parse(upload.expiresAt) <= Date.now()) response = await requestTransport(origin, `/api/skill/evidence/${upload.uploadId}`, { headers });
67
+ else response = await putScopeEvidence(origin, upload.uploadId, evidenceBytes(value), headers);
68
+ } catch { throw unknown(upload.uploadId); }
69
+ return acceptResult(response, upload, state, location, save);
70
+ }
71
+ export async function validateAppliedEvidence(record, state, location, challenge, kind) {
72
+ const reference = evidenceReference(kind === 'scope-context' ? record.attestationRef : record.candidateAttestationRef,
73
+ state.evidenceDescriptor, location);
74
+ if (reference.kind !== kind || canonical(reference) !== canonical(state.attestationRef)
75
+ || record.challengeId !== challenge.challengeId || record.challengeDigest !== sha256(canonical(challenge))
76
+ || ['ownerId', 'taskDigest', 'chainDigest'].some(field => reference[field] !== challenge[field])) evidenceFail('Applied authority differs from its original uploaded S evidence');
77
+ return readScopeEvidence(location, reference);
78
+ }
@@ -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
+ }
@@ -6,7 +6,9 @@ import { scopeFail, scopePath, scopeRoot, scopeTarget } from './local-scope-file
6
6
  import { withStateLock } from './local-state-lock.mjs';
7
7
  import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
8
8
  import { probe } from './local-probe.mjs';
9
- import { invokeRemoteResult } from './remote-client.mjs';
9
+ import { contextRemote } from './scope-context-remote.mjs';
10
+ import { scopeRunnerJournal, saveScopeRunnerJournal } from './scope-runner-journal.mjs';
11
+ import { saveLocalScopeEvidence, ensureScopeEvidence } from './scope-evidence-remote.mjs';
10
12
  import { boundScopeContexts } from './scope-context-state.mjs';
11
13
 
12
14
  async function privateDirectory(directory) {
@@ -54,38 +56,7 @@ async function signer(directory, identity, dependencies) {
54
56
  }
55
57
  return { binding, key, signerConfigPath: path.join(controls, 'signer.json'), contextAuthority };
56
58
  }
57
- async function remote(operation, input, dependencies) {
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);
59
+ async function scopeConnectionResult(identity, binding, accepted) {
89
60
  const next = accepted.nextStep ?? { operation: 'workflow-gate', payload: { targetFiles: [...identity.targetPaths] } };
90
61
  if (next.localOperation === 'scope-connect') return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'succeeded',
91
62
  flowId: identity.flowId, runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
@@ -97,6 +68,63 @@ async function connect(identity, directory, dependencies) {
97
68
  runnerId: binding.runnerId, scopeEvidence: accepted.scopeEvidence, grantsExecutionAuthority: false,
98
69
  nextOperation: next.operation, ...(resumeOriginalRequest ? { resumeOriginalRequest: true } : { nextInput }), nextStep: next };
99
70
  }
71
+ async function observeConnection(identity, location, state, challenge, contextAuthority, dependencies) {
72
+ if (state.evidenceDescriptor) return;
73
+ const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
74
+ stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: state.snapshotId, importContexts: identity.importContexts,
75
+ ...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
76
+ if (!state.snapshotStarted) {
77
+ state.snapshotStarted = true; await saveScopeRunnerJournal(location, state);
78
+ try { await snapshotPlan(input); }
79
+ catch (error) { delete state.snapshotStarted; await saveScopeRunnerJournal(location, state); throw error; }
80
+ }
81
+ const verified = await snapshotVerify(input);
82
+ if (verified.verified !== true) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
83
+ const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,
84
+ chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge, signerConfigPath: location.signerConfigPath },
85
+ { maximumExpiresAt: Date.parse(challenge.expiresAt) });
86
+ if (observed.attestation.snapshotDigest !== verified.receipt.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual S proof replaced its original verified snapshot');
87
+ await saveLocalScopeEvidence(observed.attestation, 'scope-observation', challenge, state, location, saveScopeRunnerJournal);
88
+ }
89
+ async function connect(identity, directory, dependencies) {
90
+ const local = await signer(directory, identity, dependencies), { binding, key, contextAuthority } = local;
91
+ const location = { ...local, root: identity.repositoryRoot, home: dependencies.home, base: directory,
92
+ stateRoot: path.join(dependencies.home, '.entrorove'), directory: path.join(directory, 'scope-connection'),
93
+ file: path.join(directory, 'scope-connection', 'journal.json') };
94
+ const state = await scopeRunnerJournal(location, identity);
95
+ const remote = (stage, operation, input) => contextRemote(stage, operation, input, state, location, dependencies, saveScopeRunnerJournal);
96
+ try {
97
+ if (state.pending) await remote(state.pending.stage, state.pending.operation, state.pending.input);
98
+ const prepared = state.prepared ?? await remote('prepared', 'scope-runner-prepare', { flowId: identity.flowId,
99
+ attemptId: state.attemptId, runnerId: binding.runnerId, repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths });
100
+ const enrollment = prepared.enrollment;
101
+ if (enrollment.flowId !== identity.flowId || enrollment.runnerId !== binding.runnerId || enrollment.keyId !== binding.keyId
102
+ || canonical(enrollment.targetPaths) !== canonical(identity.targetPaths)) scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud enrollment differs from the original local task signer');
103
+ if (!state.registered) await remote('registered', 'scope-runner-register', { flowId: identity.flowId, enrollmentId: enrollment.enrollmentId,
104
+ signature: sign(null, Buffer.from(canonical(enrollment)), key).toString('base64url') });
105
+ const issued = state.challenged ?? await remote('challenged', 'scope-runner-challenge', { flowId: identity.flowId,
106
+ receiverId: binding.runnerId, attemptId: state.attemptId });
107
+ const challenge = issued.challenge;
108
+ if (challenge.flowId !== identity.flowId || challenge.ownerId !== enrollment.ownerId || challenge.runnerId !== binding.runnerId
109
+ || challenge.repositoryId !== binding.repositoryId || challenge.taskDigest !== enrollment.taskDigest
110
+ || challenge.chainDigest !== enrollment.chainDigest || canonical(challenge.targetPaths) !== canonical(identity.targetPaths)) {
111
+ scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
112
+ }
113
+ if (!state.snapshotId) { state.snapshotId = `snapshot-${randomUUID()}`; await saveScopeRunnerJournal(location, state); }
114
+ if (!state.accepted) {
115
+ await observeConnection(identity, location, state, challenge, contextAuthority, dependencies);
116
+ const attestationRef = await ensureScopeEvidence(state, location, dependencies, challenge, saveScopeRunnerJournal);
117
+ await remote('accepted', 'scope-runner-attest', { flowId: identity.flowId, attestationRef });
118
+ }
119
+ state.completed = true; await saveScopeRunnerJournal(location, state);
120
+ return scopeConnectionResult(identity, binding, state.accepted);
121
+ } catch (error) {
122
+ if (!state.pending && !state.evidencePending) {
123
+ state.rejection ??= { code: error.code, message: error.message }; await saveScopeRunnerJournal(location, state);
124
+ }
125
+ throw error;
126
+ }
127
+ }
100
128
  export async function connectScopeRunner(repositoryRoot, input, dependencies) {
101
129
  if (!input || Object.keys(input).some(field => !['flowId', 'paths', 'importContexts'].includes(field))
102
130
  || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(input.flowId ?? '') || !Array.isArray(input.paths) || !input.paths.length) {
@@ -129,8 +157,10 @@ export async function runScopeConnect(repositoryRoot, input, dependencies) {
129
157
  if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
130
158
  try { return await connectScopeRunner(repositoryRoot, input, dependencies); }
131
159
  catch (error) {
132
- if (error.outcomeUnknown || !error.code?.startsWith('ENTROROVE_')) throw error;
160
+ if (!error.code?.startsWith('ENTROROVE_')) throw error;
133
161
  return { schemaVersion: 'entrorove.scope-connection/1.0', status: 'blocked', flowId: input?.flowId,
134
- code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}) };
162
+ code: error.code, message: error.message, ...(error.output ? { output: error.output } : {}),
163
+ ...(error.outcomeUnknown ? { outcomeUnknown: true, nextAction: { method: 'GET', operation: 'scope-connect',
164
+ ...(error.requestId ? { requestId: error.requestId } : { uploadId: error.uploadId }), reuseOriginalInput: true, repeatPost: false } } : {}) };
135
165
  }
136
166
  }