@entrorove/skill 0.1.68 → 0.1.69

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/remote-client.mjs CHANGED
@@ -40,6 +40,7 @@ export function buildInvocation(operation, input) {
40
40
  'handoff-prepare', 'handoff-release', 'handoff-claim', 'handoff-status', 'handoff-resume', 'handoff-execution-authority', 'mutation-snapshot-challenge', 'mutation-pass-issue', 'mutation-pass-settle',
41
41
  'member-observation-admit', 'member-observation-report', 'member-observation-status',
42
42
  'scope-runner-prepare', 'scope-runner-register', 'scope-runner-challenge', 'scope-runner-attest',
43
+ 'scope-runner-context-review', 'scope-runner-context-status', 'scope-runner-context-challenge', 'scope-runner-context-apply',
43
44
  'start', 'continue'].includes(operation)) throw new Error('Invalid entrorove operation');
44
45
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Input must be a JSON object');
45
46
  const id = requestId(operation, input);
@@ -27,7 +27,9 @@ export async function observeScopeAmendment(location, state) {
27
27
  if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
28
28
  const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
29
29
  snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
30
- contractDigest: challenge.proposalDigest, importContexts: location.binding.importContexts,
30
+ contractDigest: challenge.proposalDigest, importContexts: location.contextAuthority?.record.importContexts ?? location.binding.importContexts,
31
+ ...(location.contextAuthority ? { contextRevisionDigest: location.contextAuthority.revisionDigest,
32
+ contextEvidence: location.contextAuthority.record.evidence } : {}),
31
33
  scopeCreatePaths: challenge.files.filter(file => file.access === 'create').map(file => file.path) };
32
34
  if (!state.snapshot) {
33
35
  if (!state.snapshotStarted) {
@@ -7,6 +7,7 @@ import { retainedScope } from './local-scope-state.mjs';
7
7
  import { mutationHome14, mutationFile14, privateMutationDirectory14,
8
8
  readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
9
9
  import { trustedOrigin } from './remote-transport.mjs';
10
+ import { boundScopeContexts } from './scope-context-state.mjs';
10
11
 
11
12
  const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
12
13
  const maximumStateBytes = 1_000_000;
@@ -78,8 +79,9 @@ export async function scopeAmendmentLocation(repositoryRoot, input, dependencies
78
79
  originalFactsDigest: original.originalFactsDigest, originalSnapshotDigest: original.originalSnapshot.digest };
79
80
  const directory = path.join(base, 'amendments', sha256(canonical(input)));
80
81
  await privateMutationDirectory14(directory, { privateFrom: stateRoot });
82
+ const contextAuthority = await boundScopeContexts(base, binding, dependencies);
81
83
  return { root, home, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
82
- binding, key, signerConfigPath, original };
84
+ binding, key, signerConfigPath, original, contextAuthority };
83
85
  }
84
86
  export async function readScopeAmendmentState(location) {
85
87
  let value;
@@ -0,0 +1,134 @@
1
+ import path from 'node:path';
2
+ import { randomUUID, sign } from 'node:crypto';
3
+ import { canonical, sha256 } from './local-integrity.mjs';
4
+ import { withStateLock } from './local-state-lock.mjs';
5
+ import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files.mjs';
6
+ import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
7
+ import { contextInput, contextFail, retainedContexts, originalContextDigest, contextChallenge } from './scope-context-wire.mjs';
8
+ import { scopeContextLocation, effectiveScopeContexts, retainContextAuthority,
9
+ readContextJournal, saveContextJournal } from './scope-context-state.mjs';
10
+ import { contextRemote } from './scope-context-remote.mjs';
11
+ import { inspectContextEvidence, observeScopeContext } from './scope-context-observer.mjs';
12
+
13
+ const schemaVersion = 'entrorove.scope-context-connection/1.0';
14
+ async function archive(location, state) {
15
+ if (!state.attemptId) return;
16
+ const file = path.join(location.directory, `${state.attemptId}-${sha256(canonical(state))}.json`);
17
+ try { if (canonical(await readMutationJson14(file)) !== canonical(state)) contextFail('Context attempt history changed'); }
18
+ catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(file, state, { privateFrom: location.stateRoot }); }
19
+ }
20
+ async function journal(location, input) {
21
+ let state = await readContextJournal(location, input);
22
+ if (state.previous || input.action === 'status' && state.completed
23
+ || input.action === 'apply' && state.completed && !state.applied || state.rejection && !state.pending) {
24
+ await archive(location, state.previous ?? state); state = {};
25
+ }
26
+ if (!state.attemptId) {
27
+ state = { schemaVersion: 'entrorove.scope-context-journal/1.0', localBindingDigest: location.localBindingDigest,
28
+ input, attemptId: randomUUID() }; await saveContextJournal(location, state);
29
+ }
30
+ return state;
31
+ }
32
+ function proposalFor(status, input, location) {
33
+ const proposal = status.proposal;
34
+ if (!proposal || proposal.proposalId !== input.proposalId || proposal.proposalDigest !== input.proposalDigest
35
+ || ['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => proposal[field] !== location.binding[field])
36
+ || proposal.localBindingDigest !== location.localBindingDigest
37
+ || canonical(proposal.targetPaths) !== canonical(location.binding.targetPaths)
38
+ || !['pending', 'approved', 'declined'].includes(proposal.approval?.status)) contextFail('Current account has no matching original context proposal');
39
+ retainedContexts(location.binding.importContexts, proposal.importContexts); return proposal;
40
+ }
41
+ function waiting(value, flowId) {
42
+ const status = value.proposal?.approval?.status;
43
+ return { ...value, schemaVersion, status: status === 'declined' ? 'blocked' : 'needs-input', flowId,
44
+ grantsExecutionAuthority: false, claimsCompletion: false,
45
+ nextStep: value.nextStep ?? { operation: 'interaction-answer', requiresOriginalOwnerApproval: true,
46
+ interactionId: value.proposal?.approval?.interactionId, interactionDigest: value.proposal?.approval?.interactionDigest } };
47
+ }
48
+ async function liveStatus(location, state, dependencies) {
49
+ if (!state.checked) await contextRemote('checked', 'scope-runner-context-status', {
50
+ flowId: location.binding.flowId, attemptId: state.attemptId }, state, location, dependencies);
51
+ const effective = await effectiveScopeContexts(location);
52
+ if (!Number.isSafeInteger(state.checked.expectedRevision) || state.checked.expectedRevision !== (effective?.record.revision ?? 0)) contextFail('Live context revision differs from its retained original history');
53
+ if ((state.checked.effective?.revisionDigest ?? null) !== (effective?.revisionDigest ?? null)) contextFail('Live context head differs from retained signed history; recover its original apply request');
54
+ return state.checked;
55
+ }
56
+ async function review(location, state, dependencies) {
57
+ if (state.reviewed) return waiting(state.reviewed, location.binding.flowId);
58
+ const status = await liveStatus(location, state, dependencies), head = await effectiveScopeContexts(location), input = state.input;
59
+ retainedContexts(head?.record.importContexts ?? location.binding.importContexts, input.importContexts);
60
+ if (!state.proposal) {
61
+ const evidence = await inspectContextEvidence(location, input.importContexts, input.evidencePaths);
62
+ state.proposal = { schemaVersion: 'entrorove.scope-context-proposal/1.0', flowId: location.binding.flowId,
63
+ runnerId: location.binding.runnerId, repositoryId: location.binding.repositoryId, keyId: location.binding.keyId,
64
+ localBindingDigest: location.localBindingDigest, expectedRevision: status.expectedRevision,
65
+ previousDigest: head?.revisionDigest ?? originalContextDigest(location), originalImportContexts: location.binding.importContexts,
66
+ importContexts: input.importContexts, evidence, reason: input.reason, attemptId: state.attemptId };
67
+ state.signature = sign(null, Buffer.from(canonical(state.proposal)), location.key).toString('base64url');
68
+ await saveContextJournal(location, state);
69
+ }
70
+ const output = await contextRemote('reviewed', 'scope-runner-context-review', { flowId: location.binding.flowId,
71
+ proposal: state.proposal, signature: state.signature }, state, location, dependencies);
72
+ state.completed = true; await saveContextJournal(location, state); return waiting(output, location.binding.flowId);
73
+ }
74
+ async function applied(location, state) {
75
+ const effective = state.applied.contextAuthority;
76
+ const challenge = contextChallenge(state.challenged.challenge, location, state.proposal, { allowExpired: true });
77
+ if (state.applied.grantsExecutionAuthority !== false || !effective?.record
78
+ || canonical(effective.record.attestation) !== canonical(state.attestation)
79
+ || ['proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => effective.record[field] !== challenge[field])
80
+ || canonical(effective.record.importContexts) !== canonical(challenge.importContexts)
81
+ || canonical(effective.record.evidence) !== canonical(challenge.evidence)) contextFail('Applied context authority differs from this exact actual candidate');
82
+ const retained = await retainContextAuthority(location, effective);
83
+ state.completed = true; await saveContextJournal(location, state);
84
+ return { schemaVersion, status: 'succeeded', flowId: location.binding.flowId, contextRevisionDigest: retained.revisionDigest,
85
+ revision: retained.record.revision, grantsExecutionAuthority: false, claimsCompletion: false, nextOperation: 'scope-connect',
86
+ nextInput: { flowId: location.binding.flowId, paths: [...location.binding.targetPaths], importContexts: retained.record.importContexts } };
87
+ }
88
+ async function apply(location, state, dependencies) {
89
+ if (state.applied) return applied(location, state);
90
+ const status = await liveStatus(location, state, dependencies), proposal = proposalFor(status, state.input, location);
91
+ if (proposal.approval.status !== 'approved') {
92
+ state.completed = true; await saveContextJournal(location, state); return waiting(status, location.binding.flowId);
93
+ }
94
+ state.proposal = proposal; await saveContextJournal(location, state);
95
+ if (!state.challenged) await contextRemote('challenged', 'scope-runner-context-challenge', { flowId: location.binding.flowId,
96
+ proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attemptId: state.attemptId }, state, location, dependencies);
97
+ contextChallenge(state.challenged.challenge, location, proposal);
98
+ if (!state.attestation) {
99
+ state.attestation = await observeScopeContext(location, state, proposal); await saveContextJournal(location, state);
100
+ }
101
+ await contextRemote('applied', 'scope-runner-context-apply', { flowId: location.binding.flowId,
102
+ proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest, attestation: state.attestation }, state, location, dependencies);
103
+ return applied(location, state);
104
+ }
105
+ async function run(location, input, dependencies) {
106
+ const state = await journal(location, input);
107
+ if (state.pending) await contextRemote(state.pending.stage, state.pending.operation, state.pending.input, state, location, dependencies);
108
+ try {
109
+ if (input.action === 'review') return await review(location, state, dependencies);
110
+ if (input.action === 'apply') return await apply(location, state, dependencies);
111
+ const status = await liveStatus(location, state, dependencies);
112
+ state.completed = true; await saveContextJournal(location, state);
113
+ return { schemaVersion, ...status, status: 'succeeded', flowId: input.flowId, grantsExecutionAuthority: false, claimsCompletion: false };
114
+ } catch (error) {
115
+ if (!state.pending) { state.rejection ??= { code: error.code, message: error.message }; await saveContextJournal(location, state); }
116
+ throw error;
117
+ }
118
+ }
119
+ export async function runScopeContext(repositoryRoot, input, dependencies) {
120
+ try {
121
+ contextInput(input); await dependencies.install();
122
+ if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
123
+ await bootstrapCloudMutationAuthority(dependencies);
124
+ const location = await scopeContextLocation(repositoryRoot, input.flowId, dependencies);
125
+ return await withStateLock(path.join(location.base, 'connection'), () => run(location, input, dependencies));
126
+ } catch (error) {
127
+ return { schemaVersion, status: 'blocked', flowId: input?.flowId,
128
+ code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_CONTEXT_PREREQUISITE',
129
+ message: error?.code?.startsWith('ENTROROVE_') ? error.message : 'Context recovery stopped; preserve original signer, binding, journal and evidence.',
130
+ ...(error?.requestId ? { requestId: error.requestId } : {}), ...(error?.output ? { output: error.output } : {}),
131
+ ...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-context', method: 'GET',
132
+ requestId: error.requestId, reuseOriginalInput: true, repeatPost: false } } : {}) };
133
+ }
134
+ }
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { canonical, sha256 } from './local-integrity.mjs';
3
+ import { mutationHome14, readMutationJson14 } from './local-mutation-files.mjs';
4
+ import { contextPrivate, effectiveScopeContexts } from './scope-context-state.mjs';
5
+ import { contextExact, contextFail, contextList } from './scope-context-wire.mjs';
6
+ import { hydrateCloudMutationAuthority } from './cloud-mutation-authority.mjs';
7
+ import { trustedOrigin } from './remote-transport.mjs';
8
+
9
+ export async function scopeContextObservation(input, root, apiOrigin = 'https://entrorove.com') {
10
+ if (!input.flowId) return null;
11
+ const home = await mutationHome14(input.home), origin = trustedOrigin(apiOrigin);
12
+ const stateRoot = path.join(home, '.entrorove'), base = path.join(stateRoot, 'scope-runners', sha256(`${origin}:${input.flowId}:${root}`));
13
+ try { await contextPrivate(path.join(base, 'context-authority'), 'directory'); }
14
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
15
+ await contextPrivate(path.join(base, 'identity/binding.json'), 'file', 1_000_000);
16
+ const binding = await readMutationJson14(path.join(base, 'identity/binding.json'));
17
+ contextExact(binding, ['schemaVersion', 'flowId', 'repositoryRoot', 'apiOrigin', 'targetPaths', 'importContexts', 'runnerId', 'repositoryId', 'publicKey', 'keyId']);
18
+ if (binding.schemaVersion !== 'entrorove.local-scope-binding/1.0' || binding.flowId !== input.flowId
19
+ || binding.repositoryRoot !== root || binding.apiOrigin !== origin) contextFail('Snapshot contexts require their original local flow, root and repository binding');
20
+ contextList(binding.importContexts);
21
+ if (!await hydrateCloudMutationAuthority(home, origin)) contextFail('Snapshot context authority requires its existing private local public-key pin');
22
+ const head = await effectiveScopeContexts({ base, binding, localBindingDigest: sha256(canonical(binding)) });
23
+ if (!head) contextFail('Snapshot context history has no accepted signed head');
24
+ return { apiOrigin: origin, ...head };
25
+ }
26
+ function boundInput(input, context) {
27
+ if (!context) return input;
28
+ const fields = { importContexts: context.record.importContexts, contextRevisionDigest: context.revisionDigest,
29
+ contextEvidence: context.record.evidence };
30
+ if (Object.keys(fields).some(field => Object.hasOwn(input, field) && canonical(input[field]) !== canonical(fields[field]))) {
31
+ contextFail('Mutation observation cannot replace its original approved snapshot contexts');
32
+ }
33
+ return { ...input, ...fields };
34
+ }
35
+ export async function scopeContextSnapshotInput(input, root, context, { initial = false } = {}) {
36
+ if (context) contextExact(context, ['apiOrigin', 'record', 'revisionDigest']);
37
+ const current = await scopeContextObservation(input, root, context?.apiOrigin);
38
+ if (!context && current && !initial || context && (!current || canonical(context) !== canonical(current))) {
39
+ contextFail('Mutation snapshot context authority differs from its original signed same-flow history');
40
+ }
41
+ return { input: boundInput(input, context ?? (initial ? current : null)), context: context ?? (initial ? current : null) };
42
+ }
@@ -0,0 +1,61 @@
1
+ import { randomUUID, sign } from 'node:crypto';
2
+ import { canonical } from './local-integrity.mjs';
3
+ import { scopeRead, scopeTarget } from './local-scope-files.mjs';
4
+ import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
5
+ import { collectScopeFacts } from './local-scope-repository.mjs';
6
+ import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
7
+ import { attestScopeFacts } from './local-scope-signature.mjs';
8
+ import { contextChallenge, contextFail } from './scope-context-wire.mjs';
9
+ import { saveContextJournal } from './scope-context-state.mjs';
10
+
11
+ export async function inspectContextEvidence(location, contexts, evidencePaths) {
12
+ const paths = new Set(evidencePaths);
13
+ for (const context of contexts) {
14
+ const directory = await scopeTarget(location.root, context.sourcePrefix);
15
+ if (!directory.stat.isDirectory()) contextFail('Context source prefix must be an actual repository directory');
16
+ paths.add(context.configPath);
17
+ }
18
+ if (paths.size > 200) contextFail('Context evidence exceeds its 200-file inspection bound');
19
+ const evidence = [];
20
+ for (const relative of [...paths].sort()) {
21
+ const target = await scopeTarget(location.root, relative);
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 });
24
+ evidence.push({ path: relative, sha256: read.sha256 });
25
+ }
26
+ return evidence;
27
+ }
28
+ export async function observeScopeContext(location, state, proposal) {
29
+ const challenge = contextChallenge(state.challenged.challenge, location, proposal);
30
+ const evidence = await inspectContextEvidence(location, challenge.importContexts, challenge.evidence.map(file => file.path));
31
+ if (canonical(evidence) !== canonical(challenge.evidence)) contextFail('Actual build configuration or loader evidence changed since review');
32
+ if (!state.snapshotId) { state.snapshotId = `context-${randomUUID()}`; await saveContextJournal(location, state); }
33
+ const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
34
+ snapshotId: state.snapshotId, paths: [...location.binding.targetPaths], importContexts: challenge.importContexts,
35
+ contextRevisionDigest: proposal.proposalDigest, contextEvidence: challenge.evidence,
36
+ signerConfigPath: location.signerConfigPath, ownerId: challenge.ownerId,
37
+ task: challenge.task, taskDigest: challenge.taskDigest, chainDigest: challenge.chainDigest,
38
+ repositoryId: challenge.repositoryId, challenge: challenge.challenge };
39
+ if (!state.snapshot) {
40
+ if (state.snapshotStarted) contextFail('Interrupted context snapshot must be inspected before another observation; preserve its files');
41
+ state.snapshotStarted = true; await saveContextJournal(location, state);
42
+ try { state.snapshot = await snapshotPlan(input); }
43
+ catch (error) { delete state.snapshotStarted; await saveContextJournal(location, state); throw error; }
44
+ await saveContextJournal(location, state);
45
+ }
46
+ const verified = await snapshotVerify(input);
47
+ if (verified.verified !== true || verified.receipt.snapshotDigest !== state.snapshot.snapshotDigest) contextFail('Actual context candidate snapshot did not verify');
48
+ const snapshot = await readScopeSnapshot(input, location.root);
49
+ const facts = await collectScopeFacts(location.root, input.paths, undefined, snapshot.known, input);
50
+ facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, location.root, facts);
51
+ const observed = await attestScopeFacts(input, { repositoryRoot: location.root, facts,
52
+ snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt) });
53
+ if (!observed.attestation || canonical(observed.attestation.importContexts) !== canonical(challenge.importContexts)
54
+ || observed.attestation.contextRevisionDigest !== proposal.proposalDigest
55
+ || Date.parse(observed.attestation.expiresAt) > Date.parse(challenge.expiresAt)) contextFail('Context candidate requires its actual purpose-bound original S proof');
56
+ const after = await inspectContextEvidence(location, challenge.importContexts, challenge.evidence.map(file => file.path));
57
+ if (canonical(after) !== canonical(evidence)) contextFail('Build evidence changed while observing its actual imports');
58
+ const core = { schemaVersion: 'entrorove.scope-context-attestation/1.0', challengeId: challenge.challengeId,
59
+ proposalDigest: proposal.proposalDigest, scopeAttestation: observed.attestation };
60
+ return { ...core, signature: sign(null, Buffer.from(canonical(core)), location.key).toString('base64url') };
61
+ }
@@ -0,0 +1,8 @@
1
+ export const SCOPE_CONTEXT_SOURCE_FILES = [
2
+ 'scope-context-client.mjs', 'scope-context-observer.mjs', 'scope-context-observation.mjs', 'scope-context-remote.mjs',
3
+ 'scope-context-state.mjs', 'scope-context-wire.mjs', 'scope-context-release.mjs',
4
+ ]
5
+ export const scopeContextReleaseNote = {
6
+ zh: '新增首次范围门禁前的构建上下文纠正:原账号审核真实配置和构建证据,原 S 签名完整路径快照,通过服务挑战追加上下文权威记录;保留原 flow、绑定、旧上下文和失败历史,不授予写入或完成权限。',
7
+ en: 'Add build-context recovery before the first scope gate: original owner review of actual configuration and build evidence, original S proof of all target paths, and challenge-bound append-only context authority. Preserve the original flow, binding, contexts and failures without granting writes or completion.',
8
+ }
@@ -0,0 +1,60 @@
1
+ import { canonical } from './local-integrity.mjs';
2
+ import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
+ import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
4
+ import { ideClientHeaders } from './ide-presentation.mjs';
5
+ import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
6
+ import { readInvocations } from './local-invocations.mjs';
7
+ import { saveContextJournal } from './scope-context-state.mjs';
8
+ import { contextFail } from './scope-context-wire.mjs';
9
+
10
+ const unknown = requestId => Object.assign(new Error('Context recovery outcome is unknown; query its original request with GET only'),
11
+ { code: 'ENTROROVE_SCOPE_CONTEXT_UNKNOWN', outcomeUnknown: true, requestId });
12
+ async function recover(pending, dependencies) {
13
+ try {
14
+ const origin = dependencies.apiOrigin(), token = await dependencies.readCredential(dependencies.home);
15
+ const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
16
+ headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
17
+ const result = parseTransportResponse(response, 'original context recovery status');
18
+ if (result?.output?.requestId !== pending.requestId || !response.ok || ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
19
+ if (pending.operation !== 'scope-runner-context-status' || result.feedback) {
20
+ result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
21
+ verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
22
+ result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
23
+ }
24
+ return { response, result };
25
+ } catch { throw unknown(pending.requestId); }
26
+ }
27
+ async function notSent(pending, home) {
28
+ try {
29
+ const rows = (await readInvocations(home)).filter(row => row.kind === 'remote' && row.requestId === pending.requestId);
30
+ return !rows.length || rows.at(-1).event === 'not-sent';
31
+ } catch (error) { return error.code === 'ENOENT'; }
32
+ }
33
+ export async function contextRemote(stage, operation, input, state, location, dependencies) {
34
+ const requestId = buildInvocation(operation, input).requestId; let response, result;
35
+ if (state.pending) {
36
+ if (state.pending.stage !== stage || state.pending.requestId !== requestId || state.pending.operation !== operation
37
+ || canonical(state.pending.input) !== canonical(input)) contextFail('Recover the original context POST before another operation');
38
+ ({ response, result } = await recover(state.pending, dependencies));
39
+ } else {
40
+ state.pending = { stage, operation, input, requestId }; await saveContextJournal(location, state);
41
+ try { ({ response, result } = await invokeRemoteResult({ operation, input }, dependencies)); }
42
+ catch (error) {
43
+ if (await notSent(state.pending, dependencies.home)) {
44
+ delete state.pending; await saveContextJournal(location, state);
45
+ throw Object.assign(new Error('Context request was not transmitted; repair its prerequisite and reuse the original input'),
46
+ { code: 'ENTROROVE_SCOPE_CONTEXT_NOT_SENT', knownBeforeTransmission: true, requestId });
47
+ }
48
+ throw unknown(requestId);
49
+ }
50
+ }
51
+ if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
52
+ if (result.feedback) state.feedback = result.feedback;
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);
55
+ throw Object.assign(new Error('Original context request was rejected; preserve its exact evidence and history'),
56
+ { code: result.output.code ?? 'ENTROROVE_SCOPE_CONTEXT_REJECTED', requestId, output: result.output });
57
+ }
58
+ state[stage] = result.output.result ?? result.output; delete state.pending; await saveContextJournal(location, state);
59
+ return state[stage];
60
+ }
@@ -0,0 +1,88 @@
1
+ import { constants, promises as fs } from 'node:fs';
2
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { scopeRoot, scopePath } from './local-scope-files.mjs';
6
+ import { mutationHome14, mutationFile14, readMutationJson14, atomicMutationJson14,
7
+ privateMutationDirectory14 } from './local-mutation-files.mjs';
8
+ import { trustedOrigin } from './remote-transport.mjs';
9
+ import { contextExact, contextFail, contextList, validateContextAuthority } from './scope-context-wire.mjs';
10
+ import { hydrateCloudMutationAuthority, bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
11
+
12
+ export async function contextPrivate(file, final = 'file', maximumBytes = 8_000_000) {
13
+ const item = await mutationFile14(file, { final });
14
+ if (process.getuid && item.stat.uid !== process.getuid() || process.platform !== 'win32' && item.stat.mode & 0o077
15
+ || final === 'file' && (item.stat.nlink !== 1 || item.stat.size > maximumBytes)) contextFail('Context recovery controls must be bounded private files owned by this OS account');
16
+ return item;
17
+ }
18
+ async function privateText(file) {
19
+ const before = await contextPrivate(file, 'file', 1_000_000);
20
+ const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
21
+ try {
22
+ const opened = await handle.stat(), text = await handle.readFile('utf8'), after = await handle.stat(), latest = await contextPrivate(file);
23
+ if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode'].some(field => stat[field] !== before.stat[field]))) contextFail('Original context signer changed while reading');
24
+ return text;
25
+ } finally { await handle.close(); }
26
+ }
27
+ export async function scopeContextLocation(repositoryRoot, flowId, dependencies) {
28
+ const root = await scopeRoot(repositoryRoot), home = await mutationHome14(dependencies.home), apiOrigin = trustedOrigin(dependencies.apiOrigin());
29
+ const relative = path.relative(root, home);
30
+ if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) contextFail('Context controls must remain outside the observed repository');
31
+ const stateRoot = path.join(home, '.entrorove'), base = path.join(stateRoot, 'scope-runners', sha256(`${apiOrigin}:${flowId}:${root}`)), controls = path.join(base, 'identity');
32
+ for (const directory of [stateRoot, path.dirname(base), base, controls]) await contextPrivate(directory, 'directory');
33
+ const binding = JSON.parse(await privateText(path.join(controls, 'binding.json')));
34
+ contextExact(binding, ['schemaVersion', 'flowId', 'repositoryRoot', 'apiOrigin', 'targetPaths', 'importContexts', 'runnerId', 'repositoryId', 'publicKey', 'keyId']);
35
+ if (binding.schemaVersion !== 'entrorove.local-scope-binding/1.0' || binding.flowId !== flowId || binding.repositoryRoot !== root || binding.apiOrigin !== apiOrigin
36
+ || !Array.isArray(binding.targetPaths) || !binding.targetPaths.length
37
+ || canonical(binding.targetPaths) !== canonical([...new Set(binding.targetPaths.map(scopePath))].sort())) contextFail('The original context task binding is unavailable');
38
+ contextList(binding.importContexts);
39
+ const signerConfigPath = path.join(controls, 'signer.json'), config = JSON.parse(await privateText(signerConfigPath));
40
+ contextExact(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs']);
41
+ if (config.schemaVersion !== 'entrorove.scope-signer/1.0' || config.privateKeyPath !== path.join(controls, 'private.pem') || config.keyId !== binding.keyId
42
+ || !Number.isSafeInteger(config.receiptTtlMs) || config.receiptTtlMs < 1 || config.receiptTtlMs > 300_000) contextFail('Original context signer configuration changed');
43
+ const key = createPrivateKey(await privateText(config.privateKeyPath)), der = createPublicKey(key).export({ type: 'spki', format: 'der' });
44
+ if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey || sha256(der) !== binding.keyId) contextFail('Original context signing key changed');
45
+ return { root, home, apiOrigin, stateRoot, base, binding, key, signerConfigPath, localBindingDigest: sha256(canonical(binding)),
46
+ directory: path.join(base, 'context-recovery'), file: path.join(base, 'context-recovery', 'journal.json') };
47
+ }
48
+ export async function effectiveScopeContexts(location) {
49
+ const directory = path.join(location.base, 'context-authority');
50
+ try { await contextPrivate(directory, 'directory'); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
51
+ const names = (await fs.readdir(directory)).sort(); let current = null;
52
+ if (names.length > 100 || names.some(name => !/^\d{6}-[a-f0-9]{64}\.json$/.test(name))) contextFail('Context authority history is invalid');
53
+ for (const name of names) {
54
+ const file = path.join(directory, name); await contextPrivate(file, 'file', 1_000_000);
55
+ const saved = await readMutationJson14(file), checked = validateContextAuthority(saved.record, location, current);
56
+ if (saved.revisionDigest !== checked.revisionDigest || name !== `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`) contextFail('Context history checksum or order changed');
57
+ current = checked;
58
+ }
59
+ return current;
60
+ }
61
+ export async function boundScopeContexts(base, binding, dependencies) {
62
+ try { await contextPrivate(path.join(base, 'context-authority'), 'directory'); }
63
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
64
+ if (!await hydrateCloudMutationAuthority(dependencies.home, dependencies.apiOrigin())) await bootstrapCloudMutationAuthority(dependencies);
65
+ return effectiveScopeContexts({ base, binding, localBindingDigest: sha256(canonical(binding)) });
66
+ }
67
+ export async function retainContextAuthority(location, effective) {
68
+ const current = await effectiveScopeContexts(location);
69
+ if (current?.revisionDigest === effective?.revisionDigest && canonical(current.record) === canonical(effective.record)) return current;
70
+ const checked = validateContextAuthority(effective?.record, location, current);
71
+ if (effective.revisionDigest !== checked.revisionDigest) contextFail('Applied context authority checksum changed');
72
+ const directory = path.join(location.base, 'context-authority'), file = path.join(directory, `${String(checked.record.revision).padStart(6, '0')}-${checked.revisionDigest}.json`);
73
+ await atomicMutationJson14(file, checked, { privateFrom: location.stateRoot }); return checked;
74
+ }
75
+ export async function readContextJournal(location, input) {
76
+ try {
77
+ await contextPrivate(location.file);
78
+ const value = await readMutationJson14(location.file);
79
+ 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');
81
+ return canonical(value.input) === canonical(input) ? value : { previous: value };
82
+ } catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
83
+ }
84
+ export async function saveContextJournal(location, state) {
85
+ if (Buffer.byteLength(canonical(state)) > 8_000_000) contextFail('Context journal exceeds its bounded state limit');
86
+ await privateMutationDirectory14(location.directory, { privateFrom: location.stateRoot });
87
+ return atomicMutationJson14(location.file, state, { replace: true, privateFrom: location.stateRoot });
88
+ }
@@ -0,0 +1,84 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { scopeFail, scopePath } from './local-scope-files.mjs';
3
+ import { scopeRevisionAuthority } from './scope-amendment-wire.mjs';
4
+
5
+ const id = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
6
+ const hash = /^[a-f0-9]{64}$/;
7
+ export const contextFail = message => scopeFail('ENTROROVE_SCOPE_CONTEXT_BINDING', message);
8
+ export function contextExact(value, fields) {
9
+ if (!value || typeof value !== 'object' || Array.isArray(value)
10
+ || canonical(Object.keys(value).sort()) !== canonical([...fields].sort())) contextFail('Scope context fields are invalid');
11
+ }
12
+ export function contextList(values) {
13
+ if (!Array.isArray(values) || values.length > 100
14
+ || new Set(values.map(value => value?.sourcePrefix)).size !== values.length) contextFail('Scope contexts require unique source prefixes and at most 100 configurations');
15
+ for (const value of values) {
16
+ contextExact(value, ['sourcePrefix', 'configPath']); scopePath(value.sourcePrefix); scopePath(value.configPath);
17
+ if (!/(?:^|\/)(?:ts|js)config(?:\.[^/]+)?\.json$/.test(value.configPath)) contextFail('A context requires an actual tsconfig or jsconfig path');
18
+ }
19
+ return values;
20
+ }
21
+ export function retainedContexts(original, values) {
22
+ contextList(values);
23
+ if (original.some(prior => !values.some(value => canonical(value) === canonical(prior)))) {
24
+ contextFail('Context correction must retain every original build context unchanged');
25
+ }
26
+ }
27
+ function contextEvidence(values) {
28
+ if (!Array.isArray(values) || values.length > 200 || new Set(values.map(value => value?.path)).size !== values.length) contextFail('Context build evidence is invalid');
29
+ for (const value of values) {
30
+ contextExact(value, ['path', 'sha256']); scopePath(value.path);
31
+ if (!hash.test(value.sha256 ?? '')) contextFail('Context build evidence requires actual file hashes');
32
+ }
33
+ }
34
+ export function contextInput(input) {
35
+ const common = ['flowId', 'action'];
36
+ if (input?.action === 'review') common.push('importContexts', 'evidencePaths', 'reason');
37
+ else if (input?.action === 'apply') common.push('proposalId', 'proposalDigest');
38
+ else if (input?.action !== 'status') contextFail('Expected scope-context review, apply or status');
39
+ contextExact(input, common);
40
+ if (!id.test(input.flowId ?? '')) contextFail('Context recovery requires its original flow');
41
+ if (input.action === 'apply' && (!id.test(input.proposalId ?? '') || !hash.test(input.proposalDigest ?? ''))) contextFail('Context apply requires the original exact proposal');
42
+ if (input.action === 'review') {
43
+ contextList(input.importContexts);
44
+ if (!Array.isArray(input.evidencePaths) || !input.evidencePaths.length || input.evidencePaths.length > 100
45
+ || new Set(input.evidencePaths).size !== input.evidencePaths.length
46
+ || typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 2000) contextFail('Context review requires actual bounded build evidence and a reason');
47
+ input.evidencePaths.forEach(scopePath);
48
+ }
49
+ return input;
50
+ }
51
+ export const originalContextDigest = location => sha256(canonical({ localBindingDigest: location.localBindingDigest,
52
+ importContexts: location.binding.importContexts }));
53
+ export function validateContextAuthority(value, location, previous) {
54
+ const record = scopeRevisionAuthority(value, 'entrorove.scope-context-authority/1.0');
55
+ const expectedRevision = (previous?.record.revision ?? 0) + 1;
56
+ if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => record[field] !== location.binding[field])
57
+ || record.localBindingDigest !== location.localBindingDigest || record.revision !== expectedRevision
58
+ || record.previousDigest !== (previous?.revisionDigest ?? originalContextDigest(location))
59
+ || canonical(record.originalImportContexts) !== canonical(location.binding.importContexts)
60
+ || canonical(record.targetPaths) !== canonical(location.binding.targetPaths)
61
+ || !id.test(record.ownerId ?? '') || !hash.test(record.taskDigest ?? '') || !hash.test(record.chainDigest ?? '')
62
+ || !id.test(record.proposalId ?? '') || !hash.test(record.proposalDigest ?? '')) contextFail('Context authority replaced its original signer, paths or revision chain');
63
+ retainedContexts(previous?.record.importContexts ?? location.binding.importContexts, record.importContexts);
64
+ contextEvidence(record.evidence);
65
+ if (previous && ['ownerId', 'taskDigest', 'chainDigest'].some(field => record[field] !== previous.record[field])
66
+ || record.importContexts.some(context => !record.evidence.some(file => file.path === context.configPath))) contextFail('Context authority lost its original task or actual configuration evidence');
67
+ return { record, revisionDigest: sha256(canonical(record)) };
68
+ }
69
+ export function contextChallenge(value, location, proposal, { allowExpired = false } = {}) {
70
+ const challenge = scopeRevisionAuthority(value, 'entrorove.scope-context-challenge/1.0');
71
+ if (['flowId', 'runnerId', 'repositoryId', 'keyId'].some(field => challenge[field] !== location.binding[field])
72
+ || challenge.localBindingDigest !== location.localBindingDigest
73
+ || ['proposalId', 'proposalDigest', 'expectedRevision', 'previousDigest', 'ownerId', 'taskDigest', 'chainDigest'].some(field => challenge[field] !== proposal[field])
74
+ || canonical(challenge.targetPaths) !== canonical(location.binding.targetPaths)
75
+ || canonical(challenge.importContexts) !== canonical(proposal.importContexts)
76
+ || canonical(challenge.evidence) !== canonical(proposal.evidence)
77
+ || ['stepId', 'challengeId', 'challenge'].some(field => !id.test(challenge[field] ?? '')) || !challenge.task) contextFail('Context challenge differs from its exact original proposal');
78
+ retainedContexts(location.binding.importContexts, challenge.importContexts);
79
+ contextEvidence(challenge.evidence);
80
+ const issued = Date.parse(challenge.issuedAt), expires = Date.parse(challenge.expiresAt);
81
+ if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() + 5000
82
+ || expires <= issued || expires - issued > 90_000 || !allowExpired && expires <= Date.now()) contextFail('Context challenge is expired');
83
+ return challenge;
84
+ }
@@ -7,6 +7,7 @@ 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
9
  import { invokeRemoteResult } from './remote-client.mjs';
10
+ import { boundScopeContexts } from './scope-context-state.mjs';
10
11
 
11
12
  async function privateDirectory(directory) {
12
13
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -23,7 +24,7 @@ async function readPrivate(file) {
23
24
  const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
24
25
  try { return await handle.readFile('utf8'); } finally { await handle.close(); }
25
26
  }
26
- async function signer(directory, identity) {
27
+ async function signer(directory, identity, dependencies) {
27
28
  const controls = path.join(directory, 'identity'), file = path.join(controls, 'binding.json');
28
29
  let binding;
29
30
  try { binding = JSON.parse(await readPrivate(file)); }
@@ -41,7 +42,9 @@ async function signer(directory, identity) {
41
42
  await fs.rename(stage, controls);
42
43
  } finally { await fs.rm(stage, { recursive: true, force: true }); }
43
44
  }
44
- if (Object.keys(identity).some(field => canonical(binding[field]) !== canonical(identity[field]))) {
45
+ const contextAuthority = await boundScopeContexts(directory, binding, dependencies);
46
+ if (Object.keys(identity).filter(field => field !== 'importContexts').some(field => canonical(binding[field]) !== canonical(identity[field]))
47
+ || canonical(contextAuthority?.record.importContexts ?? binding.importContexts) !== canonical(identity.importContexts)) {
45
48
  scopeFail('ENTROROVE_SCOPE_REPLACEMENT', 'The original local task signer, repository and path set cannot be replaced');
46
49
  }
47
50
  await privateDirectory(controls);
@@ -49,7 +52,7 @@ async function signer(directory, identity) {
49
52
  if (key.asymmetricKeyType !== 'ed25519' || sha256(createPublicKey(key).export({ type: 'spki', format: 'der' })) !== binding.keyId) {
50
53
  scopeFail('ENTROROVE_SCOPE_STATE', 'Original scope signer key identity changed');
51
54
  }
52
- return { binding, key, signerConfigPath: path.join(controls, 'signer.json') };
55
+ return { binding, key, signerConfigPath: path.join(controls, 'signer.json'), contextAuthority };
53
56
  }
54
57
  async function remote(operation, input, dependencies) {
55
58
  const { response, result } = await invokeRemoteResult({ operation, input }, dependencies);
@@ -60,7 +63,7 @@ async function remote(operation, input, dependencies) {
60
63
  return result.output;
61
64
  }
62
65
  async function connect(identity, directory, dependencies) {
63
- const { binding, key, signerConfigPath } = await signer(directory, identity);
66
+ const { binding, key, signerConfigPath, contextAuthority } = await signer(directory, identity, dependencies);
64
67
  const prepared = await remote('scope-runner-prepare', { flowId: identity.flowId, attemptId: randomUUID(), runnerId: binding.runnerId,
65
68
  repositoryId: binding.repositoryId, publicKey: binding.publicKey, targetPaths: identity.targetPaths }, dependencies);
66
69
  const enrollment = prepared.enrollment;
@@ -76,7 +79,8 @@ async function connect(identity, directory, dependencies) {
76
79
  scopeFail('ENTROROVE_SCOPE_BINDING', 'Cloud scope challenge differs from the original account and accepted task');
77
80
  }
78
81
  const input = { repositoryRoot: identity.repositoryRoot, home: dependencies.home, flowId: identity.flowId,
79
- stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts };
82
+ stepId: challenge.stepId, paths: identity.targetPaths, snapshotId: `snapshot-${randomUUID()}`, importContexts: identity.importContexts,
83
+ ...(contextAuthority ? { contextRevisionDigest: contextAuthority.revisionDigest, contextEvidence: contextAuthority.record.evidence } : {}) };
80
84
  const planned = await snapshotPlan(input), verified = await snapshotVerify(input);
81
85
  if (verified.verified !== true || verified.receipt.snapshotDigest !== planned.snapshotDigest) scopeFail('ENTROROVE_SCOPE_SNAPSHOT', 'Actual copied snapshot did not verify');
82
86
  const observed = await probe({ ...input, ownerId: challenge.ownerId, task: challenge.task, taskDigest: challenge.taskDigest,