@entrorove/skill 0.1.23 → 0.1.25
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/AGREEMENT.md +6 -6
- package/CAPABILITY-MAP.md +6 -0
- package/CHANGELOG.md +10 -0
- package/SKILL.md +18 -4
- package/archguard-runtime-build.mjs +15 -0
- package/blueprint-criterion-runner.mjs +110 -0
- package/blueprint-criterion-wire.mjs +78 -0
- package/calctool-runtime-build.mjs +59 -0
- package/calctool-runtime-entry.mjs +92 -0
- package/cli.mjs +35 -83
- package/local-approved-plan.mjs +74 -15
- package/local-archguard-ast.mjs +371 -0
- package/local-archguard-checkpoint.mjs +123 -0
- package/local-archguard-components.mjs +177 -0
- package/local-archguard-contract.mjs +109 -0
- package/local-archguard-contracts.mjs +42 -0
- package/local-archguard-evaluate.mjs +120 -0
- package/local-archguard-language-ast.mjs +165 -0
- package/local-archguard-mutation-fence.mjs +28 -0
- package/local-archguard-operations.mjs +25 -0
- package/local-archguard-scope.mjs +64 -0
- package/local-archguard-signing.mjs +52 -0
- package/local-archguard-snapshot.mjs +126 -0
- package/local-archguard-state.mjs +200 -0
- package/local-archguard.mjs +74 -0
- package/local-budget-git-attributes.mjs +49 -0
- package/local-budget-git-loader.mjs +61 -0
- package/local-budget-git-native-attributes.mjs +81 -0
- package/local-budget-git-native-darwin.mjs +104 -0
- package/local-budget-git-native-linux.mjs +93 -0
- package/local-budget-git-native-reads.mjs +88 -0
- package/local-budget-git-native-request.mjs +53 -0
- package/local-budget-git-native-runtime.mjs +18 -0
- package/local-budget-git-native.c +313 -0
- package/local-budget-git-native.mjs +109 -0
- package/local-budget-git-outcome.mjs +33 -0
- package/local-budget-git-scratch-loader.mjs +158 -0
- package/local-budget-git-scratch.mjs +112 -0
- package/local-budget-git-system-attributes.mjs +44 -0
- package/local-budget-git.mjs +92 -0
- package/local-budget-grants.mjs +82 -0
- package/local-budget-handoff-completion.mjs +70 -0
- package/local-budget-handoff-contracts.mjs +53 -0
- package/local-budget-handoff.mjs +229 -0
- package/local-budget-lock.mjs +55 -0
- package/local-budget-reads.mjs +135 -0
- package/local-budget-renewal.mjs +86 -34
- package/local-budget-source-bindings.mjs +51 -0
- package/local-budget-store.mjs +131 -0
- package/local-budget-transition.mjs +46 -0
- package/local-budget.mjs +185 -0
- package/local-calctool-cli.mjs +24 -0
- package/local-calctool-contracts.mjs +42 -0
- package/local-calctool-runtime.mjs +10 -0
- package/local-calctool-storage.mjs +101 -0
- package/local-calctool.mjs +103 -0
- package/local-chain-bindings.mjs +93 -0
- package/local-chain-broker.mjs +61 -0
- package/local-chain-contracts.mjs +21 -0
- package/local-chain-controller.mjs +321 -0
- package/local-chain-human.mjs +48 -0
- package/local-chain-lock.mjs +54 -0
- package/local-chain-operations.mjs +7 -0
- package/local-chain-plan.mjs +232 -0
- package/local-chain-release-labels.mjs +12 -0
- package/local-chain-results.mjs +75 -0
- package/local-cli.mjs +65 -24
- package/local-contract-types.mjs +46 -0
- package/local-contracts.mjs +126 -0
- package/local-credential.mjs +101 -0
- package/local-decimal.mjs +85 -0
- package/local-error.mjs +23 -0
- package/local-evidence-verification.mjs +199 -0
- package/local-execution.mjs +115 -304
- package/local-final-gate16-bundle.mjs +21 -0
- package/local-final-gate16-checks.mjs +88 -0
- package/local-final-gate16-contract.mjs +15 -0
- package/local-final-gate16-plan.mjs +66 -0
- package/local-final-gate16-proof.mjs +75 -0
- package/local-final-gate16-release.mjs +17 -0
- package/local-final-gate16-state.mjs +70 -0
- package/local-final-gate16-worker.mjs +19 -0
- package/local-final-gate16.mjs +137 -0
- package/local-formula-ast.mjs +106 -0
- package/local-formula-engine.mjs +125 -0
- package/local-formula-tests.mjs +61 -0
- package/local-formula.mjs +25 -125
- package/local-integrity.mjs +22 -41
- package/local-invocations.mjs +142 -0
- package/local-ledger.mjs +15 -9
- package/local-merge-archguard-verification.d.mts +44 -0
- package/local-merge-archguard-verification.mjs +240 -0
- package/local-merge-archguard.mjs +138 -0
- package/local-merge-artifacts.mjs +86 -0
- package/local-merge-authority.mjs +89 -0
- package/local-merge-contracts.mjs +56 -0
- package/local-merge-controller.mjs +170 -0
- package/local-merge-head.mjs +56 -0
- package/local-merge-journal.mjs +77 -0
- package/local-merge-mutation.mjs +97 -0
- package/local-merge-rollback.mjs +126 -0
- package/local-merge-snapshot-rollback.mjs +26 -0
- package/local-merge-storage.mjs +103 -0
- package/local-merge-targets.mjs +47 -0
- package/local-merge-transaction.mjs +133 -0
- package/local-mutation-audit.mjs +34 -0
- package/local-mutation-authority.mjs +103 -0
- package/local-mutation-batch-receipt.mjs +84 -0
- package/local-mutation-batch.mjs +143 -0
- package/local-mutation-binding.mjs +89 -0
- package/local-mutation-files.mjs +270 -0
- package/local-mutation-pass-store.mjs +63 -0
- package/local-mutation-snapshot.mjs +200 -0
- package/local-mutation-wire.mjs +89 -0
- package/local-probe.mjs +133 -11
- package/local-scope-candidate.mjs +28 -0
- package/local-scope-context.mjs +28 -0
- package/local-scope-files.mjs +106 -0
- package/local-scope-git-attributes.mjs +200 -0
- package/local-scope-git-comparison.mjs +8 -0
- package/local-scope-git-history.mjs +72 -0
- package/local-scope-git-reads.mjs +66 -0
- package/local-scope-git.mjs +124 -0
- package/local-scope-repository.mjs +249 -0
- package/local-scope-signature.mjs +70 -0
- package/local-scope-snapshot.mjs +39 -0
- package/local-scope-state.mjs +204 -0
- package/local-scope-workspaces.mjs +83 -0
- package/local-swarm-authority.mjs +143 -0
- package/local-swarm-baseline.mjs +32 -0
- package/local-swarm-completion.mjs +102 -0
- package/local-swarm-contracts.mjs +47 -0
- package/local-swarm-mutation.mjs +142 -0
- package/local-swarm-priority.mjs +237 -0
- package/local-swarm-state.mjs +102 -0
- package/local-swarm-storage.mjs +135 -0
- package/local-swarm.mjs +179 -0
- package/local-task-handoff-completion-wire.mjs +71 -0
- package/local-task-handoff-contracts.mjs +66 -0
- package/local-task-handoff-fence.mjs +478 -0
- package/local-task-handoff-observation.mjs +41 -0
- package/local-task-handoff-overlay-wire.mjs +94 -0
- package/local-task-handoff-read-authority.mjs +236 -0
- package/local-task-handoff-storage.mjs +73 -0
- package/local-task-handoff-write-archive.mjs +80 -0
- package/local-task-handoff-write-constraints.mjs +66 -0
- package/local-task-handoff-write-receipts.mjs +99 -0
- package/local-task-handoff-write-wire.mjs +99 -0
- package/local-task-routing.mjs +202 -22
- package/manifest.json +1 -1
- package/package.json +164 -3
- package/release-state.json +164 -18
- package/release.mjs +242 -4
- package/releases.json +12 -0
- package/remote-client.mjs +127 -36
- package/remote-transport.mjs +80 -0
- package/review-client.mjs +38 -0
- package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
- package/runtime/calctool-runtime-v7.0.43.mjs +1910 -0
- package/runtime/git-native-darwin-v1.mjs +5 -0
- package/runtime/git-native-linux-v1.mjs +5 -0
- package/task-message-cli.mjs +47 -0
- package/task-message-entry.mjs +159 -0
- package/workflow-local-client.mjs +35 -0
- package/workflow-local-contracts.mjs +58 -0
- package/workflow-local-request.mjs +53 -0
- package/workflow-local-runner.mjs +350 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { canonical, fail, identifier, normalizeScope, READ_BUDGETS, sha, verifyBudgetGrant } from './local-budget-grants.mjs';
|
|
5
|
+
import { budgetLocation, commitBudget, withBudget } from './local-budget-store.mjs';
|
|
6
|
+
import { budgetView } from './local-budget.mjs';
|
|
7
|
+
import { assertBudgetHandoffCompletionRead, budgetHandoffCompletionValidationState } from './local-budget-handoff-completion.mjs';
|
|
8
|
+
|
|
9
|
+
const provenanceFields = ['stage', 'handoffId', 'messageRequestId', 'previousOwnerTaskId', 'previousOwnerFlowId',
|
|
10
|
+
'previousOwnerScopeDigest', 'receiverTaskId', 'epoch', 'receiverEpoch', 'challengeId', 'passDigest',
|
|
11
|
+
'sourceReleaseReceiptDigest', 'bindingDigest', 'challengeDigest', 'leaseDigest'];
|
|
12
|
+
const candidateLeaseFields = ['schemaVersion', 'purpose', 'leaseId', 'handoffId', 'messageRequestId', 'ownerId', 'taskId',
|
|
13
|
+
'flowId', 'stepId', 'taskDigest', 'chainDigest', 'challengeId', 'runnerKeyId', 'localRepositoryDigest', 'epoch',
|
|
14
|
+
'receiverEpoch', 'baselineReceiptDigest', 'paths', 'mutationAllowed', 'issuedAt', 'expiresAt'];
|
|
15
|
+
const restoreContext = new AsyncLocalStorage();
|
|
16
|
+
function denied(message) { fail('ENTROROVE_BUDGET_HANDOFF_REVOKED', message); }
|
|
17
|
+
function snapshotInput(input) {
|
|
18
|
+
const fields = ['home', 'repositoryRoot', 'flowId', 'mode', 'executionContext', 'grant', 'handoffCandidate',
|
|
19
|
+
'provisionalLease', 'pass', 'lease', 'challenge', 'authorization'];
|
|
20
|
+
if (Object.keys(input).some(field => !fields.includes(field))) denied('handoff budget input contains an unsupported field');
|
|
21
|
+
try { return structuredClone(input); } catch { denied('handoff budget controls must be immutable data'); }
|
|
22
|
+
}
|
|
23
|
+
function validateProvenance(grant) {
|
|
24
|
+
const h = grant.handoff;
|
|
25
|
+
if (grant.purpose !== 'handoff-read' || !h || Object.keys(h).sort().join('\0') !== [...provenanceFields].sort().join('\0')
|
|
26
|
+
|| !['released', 'claimed'].includes(h.stage)) denied('handoff budget requires its exact signed phase provenance');
|
|
27
|
+
for (const field of ['handoffId', 'messageRequestId', 'previousOwnerTaskId', 'previousOwnerFlowId', 'receiverTaskId', 'challengeId']) identifier(h[field], field);
|
|
28
|
+
for (const field of ['previousOwnerScopeDigest', 'sourceReleaseReceiptDigest', 'bindingDigest', 'challengeDigest', 'leaseDigest']) {
|
|
29
|
+
if (!/^[0-9a-f]{64}$/.test(h[field] ?? '')) denied('handoff provenance digest is invalid');
|
|
30
|
+
}
|
|
31
|
+
if (!Number.isSafeInteger(h.epoch) || h.epoch < 1 || !Number.isSafeInteger(h.receiverEpoch) || h.receiverEpoch < 1
|
|
32
|
+
|| (h.stage === 'released' ? h.passDigest !== null : !/^[0-9a-f]{64}$/.test(h.passDigest ?? ''))
|
|
33
|
+
|| grant.stepId !== `handoff-${h.handoffId}`) denied('handoff provenance epoch, pass or step is invalid');
|
|
34
|
+
return h;
|
|
35
|
+
}
|
|
36
|
+
function proofFor(input, stage) {
|
|
37
|
+
return stage === 'released' ? { handoffCandidate: input.handoffCandidate, provisionalLease: input.provisionalLease }
|
|
38
|
+
: { pass: input.pass, lease: input.lease, challenge: input.challenge };
|
|
39
|
+
}
|
|
40
|
+
function checkCommon(grant, h, challenge, lease) {
|
|
41
|
+
const pairs = { handoffId: h.handoffId, messageRequestId: h.messageRequestId, ownerId: grant.ownerId,
|
|
42
|
+
flowId: grant.flowId, localRepositoryDigest: grant.repositoryDigest, epoch: h.epoch, receiverEpoch: h.receiverEpoch,
|
|
43
|
+
challengeId: h.challengeId };
|
|
44
|
+
if (Object.entries(pairs).some(([field, value]) => challenge[field] !== value || lease[field] !== value)
|
|
45
|
+
|| challenge.previousOwnerTaskId !== h.previousOwnerTaskId || challenge.previousOwnerFlowId !== h.previousOwnerFlowId
|
|
46
|
+
|| challenge.originalScopeDigest !== h.previousOwnerScopeDigest || challenge.receiverTaskId !== h.receiverTaskId
|
|
47
|
+
|| lease.taskId !== h.receiverTaskId || lease.stepId !== grant.stepId || lease.leaseId !== grant.leaseId
|
|
48
|
+
|| grant.leaseGeneration !== (lease.generation ?? 1) || grant.expiresAt !== lease.expiresAt
|
|
49
|
+
|| challenge.baselineReceiptDigest !== h.sourceReleaseReceiptDigest
|
|
50
|
+
|| canonical([...challenge.paths].sort()) !== canonical(grant.scope.allowedPaths)
|
|
51
|
+
|| canonical(challenge.paths) !== canonical(lease.paths)) denied('handoff budget lost its original receiver, source permission or short lease');
|
|
52
|
+
}
|
|
53
|
+
async function nativeReadState(input, local, grant, h) {
|
|
54
|
+
const home = await fs.realpath(input.home ?? os.homedir()), root = await fs.realpath(input.repositoryRoot);
|
|
55
|
+
if (input.flowId !== local.flowId || home !== local.home || root !== local.root) denied('handoff source read cannot replace its original local receiver binding');
|
|
56
|
+
const native = await import('./local-task-handoff-fence.mjs');
|
|
57
|
+
let state;
|
|
58
|
+
try {
|
|
59
|
+
const authority = await import('./local-task-handoff-read-authority.mjs');
|
|
60
|
+
const registered = await authority.assertTaskHandoffReadHome({ ...input, taskId: h.receiverTaskId, ownerId: grant.ownerId });
|
|
61
|
+
state = await native.readTaskHandoffFence(input);
|
|
62
|
+
if (registered.readHome !== home || registered.taskId !== h.receiverTaskId || registered.ownerId !== grant.ownerId
|
|
63
|
+
|| registered.epoch !== state.epoch || registered.mutationAllowed !== false) denied('native read registration replaced its original receiver binding');
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error.code === 'ENTROROVE_BUDGET_HANDOFF_REVOKED') throw error;
|
|
66
|
+
fail('ENTROROVE_BUDGET_HANDOFF_NATIVE_REQUIRED', 'handoff reading requires its completed trusted receiver read-home registration');
|
|
67
|
+
}
|
|
68
|
+
return { native, state };
|
|
69
|
+
}
|
|
70
|
+
async function registerReleasedReadHome(input) {
|
|
71
|
+
const location = await budgetLocation(input, true), grant = verifyBudgetGrant(input.grant, location, input.grant?.kind);
|
|
72
|
+
const h = validateProvenance(grant);
|
|
73
|
+
if (h.stage !== 'released') return;
|
|
74
|
+
try {
|
|
75
|
+
const wire = await import('./local-task-handoff-overlay-wire.mjs');
|
|
76
|
+
const challenge = wire.overlayAuthority(input.handoffCandidate, wire.HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0');
|
|
77
|
+
const lease = wire.overlayAuthority(input.provisionalLease, candidateLeaseFields, 'entrorove.task-handoff-provisional-lease/1.0');
|
|
78
|
+
if (sha(canonical(input.handoffCandidate)) !== h.challengeDigest || sha(canonical(input.provisionalLease)) !== h.leaseDigest
|
|
79
|
+
|| lease.purpose !== 'scope-inspection' || lease.mutationAllowed !== false) denied('released read registration requires its exact signed candidate and lease');
|
|
80
|
+
checkCommon(grant, h, challenge, lease);
|
|
81
|
+
const authority = await import('./local-task-handoff-read-authority.mjs');
|
|
82
|
+
await authority.registerTaskHandoffReadAuthority(input);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === 'ENTROROVE_BUDGET_HANDOFF_REVOKED') throw error;
|
|
85
|
+
fail('ENTROROVE_BUDGET_HANDOFF_NATIVE_REQUIRED', 'released reading could not complete its trusted receiver read-home registration');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function budgetHandoffReadValidationState(state) {
|
|
89
|
+
const restoration = restoreContext.getStore();
|
|
90
|
+
if (!restoration || state.grant?.grantId !== restoration.grant.previousGrantId) return budgetHandoffCompletionValidationState(state);
|
|
91
|
+
return { ...state, grant: { ...state.grant, expiresAt: new Date(Math.min(Date.parse(restoration.authorization.expiresAt),
|
|
92
|
+
Date.parse(restoration.grant.expiresAt))).toISOString() } };
|
|
93
|
+
}
|
|
94
|
+
export async function assertBudgetHandoffReadAllowed(input, state) {
|
|
95
|
+
if (state.grant?.purpose !== 'handoff-read') return;
|
|
96
|
+
const grant = state.grant, h = validateProvenance(grant), retained = state.handoffRead;
|
|
97
|
+
if (!retained || canonical(retained.handoff) !== canonical(h)) denied('handoff reading requires its retained native proof');
|
|
98
|
+
try {
|
|
99
|
+
const { native, state: current } = await nativeReadState(input, retained.local, grant, h);
|
|
100
|
+
if (assertBudgetHandoffCompletionRead(input, state, current)) return;
|
|
101
|
+
const restoration = restoreContext.getStore();
|
|
102
|
+
if (restoration) {
|
|
103
|
+
if (input.sourceRole?.startsWith('git-') || state.grant.grantId !== restoration.grant.previousGrantId || !current.receiverCompletion
|
|
104
|
+
|| current.status !== 'paused' || current.receiverRestoration || !current.receiverCompletion.receipt.paths.includes(input.path)) denied('finalization cannot read outside its exact paused completion proof');
|
|
105
|
+
await validateRestoration(state, restoration.grant, restoration.authorization, input, current); return;
|
|
106
|
+
}
|
|
107
|
+
const wire = await import('./local-task-handoff-overlay-wire.mjs'); const proof = retained.proof;
|
|
108
|
+
if (h.stage === 'released') {
|
|
109
|
+
if (sha(canonical(proof.handoffCandidate)) !== h.challengeDigest || sha(canonical(proof.provisionalLease)) !== h.leaseDigest) denied('candidate controls do not match the signed handoff digests');
|
|
110
|
+
const challenge = wire.overlayAuthority(proof.handoffCandidate, wire.HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0');
|
|
111
|
+
const lease = wire.overlayAuthority(proof.provisionalLease, candidateLeaseFields, 'entrorove.task-handoff-provisional-lease/1.0');
|
|
112
|
+
checkCommon(grant, h, challenge, lease);
|
|
113
|
+
if (lease.purpose !== 'scope-inspection' || lease.mutationAllowed !== false || current.status !== 'active'
|
|
114
|
+
|| current.receiverClaim && !current.receiverRestoration) denied('released handoff candidate is no longer current');
|
|
115
|
+
const authority = await import('./local-task-handoff-read-authority.mjs');
|
|
116
|
+
const registered = await authority.readTaskHandoffReadAuthority({ ...input, ...proof });
|
|
117
|
+
if (registered.candidateDigest !== h.challengeDigest || registered.leaseDigest !== h.leaseDigest
|
|
118
|
+
|| registered.readHome !== retained.local.home) denied('released read lost its exact signed home registration');
|
|
119
|
+
const observed = await native.readTaskHandoffSnapshotBinding({ ...input, stepId: grant.stepId,
|
|
120
|
+
paths: grant.scope.allowedPaths, handoffCandidate: proof.handoffCandidate });
|
|
121
|
+
if (observed.handoffEpoch !== h.receiverEpoch || observed.handoffBaselineReceiptDigest !== h.sourceReleaseReceiptDigest) denied('candidate read lost its real native epoch or baseline');
|
|
122
|
+
} else {
|
|
123
|
+
if (current.status !== 'active' || current.epoch !== h.receiverEpoch || current.receiverCompletion || current.receiverRestoration
|
|
124
|
+
|| !current.receiverClaim || canonical(current.receiverClaim) !== canonical(proof)) denied('claimed handoff reading was completed, restored or replaced');
|
|
125
|
+
const accepted = wire.receiverClaim(current.receiverClaim, { flowId: input.flowId, repo: grant.repositoryDigest });
|
|
126
|
+
checkCommon(grant, h, accepted.challenge, accepted.lease);
|
|
127
|
+
if (sha(canonical(proof.pass)) !== h.passDigest || sha(canonical(proof.challenge)) !== h.challengeDigest
|
|
128
|
+
|| sha(canonical(proof.lease)) !== h.leaseDigest || current.revokedPassIds.includes(accepted.pass.passId)
|
|
129
|
+
|| current.revokedLeaseIds.includes(grant.leaseId)
|
|
130
|
+
|| canonical(grant.scope.forbiddenPaths ?? []) !== canonical([...accepted.pass.previousOwnerScopeContract.forbiddenPaths].sort())) denied('claimed read lost its exact native pass, lease or inherited prohibitions');
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (['ENTROROVE_BUDGET_HANDOFF_REVOKED', 'ENTROROVE_BUDGET_HANDOFF_NATIVE_REQUIRED'].includes(error.code)) throw error;
|
|
134
|
+
denied('handoff native proof is unavailable, expired or revoked');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function unchangedAllowance(state, grant) {
|
|
138
|
+
if (grant.mode !== state.mode || grant.scope.goal !== state.scope.goal || canonical(grant.limits) !== canonical(state.grant.limits)
|
|
139
|
+
|| grant.additions || grant.policy) denied('handoff reading cannot replace its original goal, mode or quota');
|
|
140
|
+
}
|
|
141
|
+
function initialState(location, input, grant) {
|
|
142
|
+
if (canonical(input.executionContext) !== canonical({ executionIsolation: 'sandbox', cloudSandboxEnabled: true })
|
|
143
|
+
|| input.mode !== grant.mode || grant.kind !== 'init' || grant.previousGrantId !== null || grant.revision !== 1
|
|
144
|
+
|| canonical(grant.limits) !== canonical(READ_BUDGETS[grant.mode])) denied('initial handoff grant must retain the original fixed mode allowance');
|
|
145
|
+
return { schemaVersion: 'entrorove.read-budget/2.0', flowId: location.flowId, repositoryDigest: location.repositoryDigest,
|
|
146
|
+
mode: grant.mode, executionContext: input.executionContext, enforced: true, startedAt: new Date().toISOString(), elapsedMs: 0,
|
|
147
|
+
...READ_BUDGETS[grant.mode], files: [], readCalls: 0, tokenEstimate: 0, extensions: [], grant, initialGrant: grant,
|
|
148
|
+
scope: grant.scope, revision: 1, closedReason: null, autoRenew: null };
|
|
149
|
+
}
|
|
150
|
+
export async function budgetHandoff(input) {
|
|
151
|
+
input = snapshotInput(input);
|
|
152
|
+
if (input.grant?.kind === 'handoff-restore') return restoreHandoffBudget(input);
|
|
153
|
+
if (input.executionContext && (input.executionContext.executionIsolation !== 'sandbox' || input.executionContext.cloudSandboxEnabled !== true)) denied('handoff budget requires a trusted sandbox context');
|
|
154
|
+
await registerReleasedReadHome(input);
|
|
155
|
+
return withBudget(input, async (location, loaded) => {
|
|
156
|
+
const grant = verifyBudgetGrant(input.grant, location, input.grant?.kind), h = validateProvenance(grant);
|
|
157
|
+
let state = loaded?.state;
|
|
158
|
+
if (state) {
|
|
159
|
+
if (!state.enforced || state.closedReason || grant.previousGrantId !== state.grant.grantId || grant.revision !== state.revision + 1
|
|
160
|
+
|| grant.ownerId !== state.grant.ownerId) denied('handoff grant must advance its original live receiver budget exactly once');
|
|
161
|
+
unchangedAllowance(state, grant);
|
|
162
|
+
if (grant.kind === 'handoff-read') {
|
|
163
|
+
if (state.grant.purpose === 'handoff-read' || h.stage !== 'released' || state.autoRenew?.status === 'authorized') denied('handoff entry requires its original receiver budget without active renewal');
|
|
164
|
+
} else if (grant.kind === 'handoff-claim') {
|
|
165
|
+
const previous = state.grant.handoff;
|
|
166
|
+
if (state.grant.purpose !== 'handoff-read' || previous.stage !== 'released' || h.stage !== 'claimed'
|
|
167
|
+
|| provenanceFields.filter(field => !['stage', 'passDigest', 'leaseDigest'].includes(field)).some(field => h[field] !== previous[field])) denied('handoff claim must advance the same original released candidate');
|
|
168
|
+
} else denied('existing receiver budgets cannot be reinitialized for handoff reading');
|
|
169
|
+
} else state = initialState(location, input, grant);
|
|
170
|
+
const local = { home: await fs.realpath(input.home ?? os.homedir()), root: location.root, flowId: location.flowId };
|
|
171
|
+
const handoffRead = { handoff: h, local, proof: proofFor(input, h.stage),
|
|
172
|
+
previousBudget: state.handoffRead ? state.handoffRead.previousBudget : (loaded ? { grant: state.grant, scope: state.scope, mode: state.mode } : null) };
|
|
173
|
+
const updated = { ...state, grant, scope: normalizeScope(grant.scope), revision: grant.revision, handoffRead };
|
|
174
|
+
await assertBudgetHandoffReadAllowed(input, updated);
|
|
175
|
+
const committed = await commitBudget(location, loaded, updated, `budget-${grant.kind}`, { grantId: grant.grantId, handoff: h });
|
|
176
|
+
await assertBudgetHandoffReadAllowed(input, committed); return budgetView(committed);
|
|
177
|
+
}, true);
|
|
178
|
+
}
|
|
179
|
+
async function validateRestoration(state, grant, authorization, input, current) {
|
|
180
|
+
const h = state.grant.handoff, r = grant.handoffRestore;
|
|
181
|
+
const fields = ['handoffId', 'messageRequestId', 'passDigest', 'leaseDigest', 'receiverEpoch', 'restorationDigest'];
|
|
182
|
+
if (!r || Object.keys(r).sort().join('\0') !== fields.sort().join('\0') || h?.stage !== 'claimed'
|
|
183
|
+
|| fields.filter(field => field !== 'restorationDigest').some(field => r[field] !== h[field])
|
|
184
|
+
|| r.restorationDigest !== sha(canonical(authorization)) || grant.handoff || grant.purpose === 'handoff-read'
|
|
185
|
+
|| grant.flowId !== state.flowId || grant.repositoryDigest !== state.repositoryDigest || grant.ownerId !== state.grant.ownerId
|
|
186
|
+
|| grant.previousGrantId !== state.grant.grantId || grant.revision !== state.revision + 1 || state.closedReason) denied('restoration lost its original handoff grant lineage');
|
|
187
|
+
unchangedAllowance(state, grant);
|
|
188
|
+
const wire = await import('./local-task-handoff-overlay-wire.mjs');
|
|
189
|
+
const restored = wire.receiverRestoration(authorization, current.receiverClaim,
|
|
190
|
+
{ flowId: input.flowId, repo: grant.repositoryDigest });
|
|
191
|
+
if (!current.receiverCompletion || sha(canonical(current.receiverCompletion.receipt)) !== restored.completionIdleReceiptDigest
|
|
192
|
+
|| restored.baselineReceiptDigest !== restored.completionIdleReceiptDigest
|
|
193
|
+
|| current.epoch !== (current.receiverRestoration ? restored.epoch : restored.previousEpoch)
|
|
194
|
+
|| (current.receiverRestoration ? current.status !== 'active' || canonical(current.receiverRestoration) !== canonical(authorization)
|
|
195
|
+
: current.status !== 'paused')) denied('restoration requires the actual completed native proof and its exact next receiver epoch');
|
|
196
|
+
const previous = state.handoffRead.previousBudget;
|
|
197
|
+
if (previous ? canonical(grant.scope) !== canonical(previous.scope) || grant.mode !== previous.mode
|
|
198
|
+
|| ['stepId', 'leaseId', 'leaseGeneration', 'expiresAt'].some(field => grant[field] !== previous.grant[field])
|
|
199
|
+
: canonical(grant.scope.allowedPaths) !== canonical([...restored.paths].sort())) denied('restoration cannot expand or replace the original receiver scope or live lease');
|
|
200
|
+
if (!restored.revokedPassIds.every(id => current.revokedPassIds.includes(id))
|
|
201
|
+
|| !restored.revokedLeaseIds.every(id => current.revokedLeaseIds.includes(id))) denied('native completion has not revoked the original handoff write pass and short lease');
|
|
202
|
+
return restored;
|
|
203
|
+
}
|
|
204
|
+
async function restoreHandoffBudget(input) {
|
|
205
|
+
const grant = verifyBudgetGrant(input.grant, input, 'handoff-restore'), authorization = input.authorization;
|
|
206
|
+
const preparation = await withBudget(input, async (location, loaded) => {
|
|
207
|
+
const state = loaded.state;
|
|
208
|
+
if (canonical(state.grant) === canonical(grant)) return { done: budgetView(state) };
|
|
209
|
+
const native = await nativeReadState(input, state.handoffRead?.local ?? {}, state.grant, state.grant.handoff ?? {});
|
|
210
|
+
await validateRestoration(state, grant, authorization, input, native.state);
|
|
211
|
+
return { native: native.native, alreadyRestored: Boolean(native.state.receiverRestoration) };
|
|
212
|
+
});
|
|
213
|
+
if (preparation.done) return preparation.done;
|
|
214
|
+
if (!preparation.alreadyRestored) await restoreContext.run(Object.freeze({ grant, authorization }),
|
|
215
|
+
() => preparation.native.restoreTaskHandoffReceiverScope({ ...input, authorization }));
|
|
216
|
+
return withBudget(input, async (location, loaded) => {
|
|
217
|
+
const state = loaded.state;
|
|
218
|
+
if (canonical(state.grant) === canonical(grant)) return budgetView(state);
|
|
219
|
+
const native = await nativeReadState(input, state.handoffRead?.local ?? {}, state.grant, state.grant.handoff ?? {});
|
|
220
|
+
await validateRestoration(state, grant, authorization, input, native.state);
|
|
221
|
+
if (!native.state.receiverRestoration) denied('native restoration must finish before original receiver reading resumes');
|
|
222
|
+
const updated = { ...state, grant, scope: grant.scope, revision: grant.revision, handoffRead: null,
|
|
223
|
+
handoffHistory: [...(state.handoffHistory ?? []), { handoff: state.grant.handoff, grantId: state.grant.grantId,
|
|
224
|
+
restoreGrantId: grant.grantId, restorationDigest: grant.handoffRestore.restorationDigest }] };
|
|
225
|
+
return budgetView(await commitBudget(location, loaded, updated, 'budget-handoff-restore', { grantId: grant.grantId,
|
|
226
|
+
previousGrantId: grant.previousGrantId, restorationDigest: grant.handoffRestore.restorationDigest }));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
export const LOCAL_BUDGET_HANDOFF_OPERATIONS = Object.freeze({ 'budget-handoff': budgetHandoff });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { fail } from './local-budget-grants.mjs';
|
|
4
|
+
|
|
5
|
+
function alive(pid) {
|
|
6
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return false;
|
|
7
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
|
|
8
|
+
}
|
|
9
|
+
async function owner(lock) {
|
|
10
|
+
try { return JSON.parse(await fs.readFile(`${lock}/owner.json`, 'utf8')); }
|
|
11
|
+
catch (error) { if (error.code === 'ENOENT') return null; fail('ENTROROVE_LOCK_INVALID', 'budget lock owner is unreadable'); }
|
|
12
|
+
}
|
|
13
|
+
async function recoverDead(lock) {
|
|
14
|
+
let stat;
|
|
15
|
+
try { stat = await fs.lstat(lock); } catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
16
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_LOCK_INVALID', 'budget lock must be a real directory');
|
|
17
|
+
const previous = await owner(lock);
|
|
18
|
+
if (previous && alive(previous.pid) || !previous && Date.now() - stat.mtimeMs < 1000) return false;
|
|
19
|
+
const quarantine = `${lock}.dead-${randomUUID()}`;
|
|
20
|
+
try { await fs.rename(lock, quarantine); } catch (error) { if (error.code === 'ENOENT') return true; throw error; }
|
|
21
|
+
const moved = await fs.lstat(quarantine);
|
|
22
|
+
if (moved.ino !== stat.ino || moved.dev !== stat.dev) {
|
|
23
|
+
await fs.rename(quarantine, lock).catch(() => undefined);
|
|
24
|
+
fail('ENTROROVE_LOCK_CHANGED', 'budget lock changed during dead process recovery');
|
|
25
|
+
}
|
|
26
|
+
await fs.rm(quarantine, { recursive: true }); return true;
|
|
27
|
+
}
|
|
28
|
+
async function acquire(lock, token) {
|
|
29
|
+
const staging = `${lock}.pending-${process.pid}-${token}`; const deadline = Date.now() + 10000;
|
|
30
|
+
await fs.mkdir(staging, { mode: 0o700 });
|
|
31
|
+
try {
|
|
32
|
+
await fs.writeFile(`${staging}/owner.json`, `${JSON.stringify({ pid: process.pid, token })}\n`, { flag: 'wx', mode: 0o600 });
|
|
33
|
+
while (true) {
|
|
34
|
+
let existing;
|
|
35
|
+
try { existing = await fs.lstat(lock); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
36
|
+
if (!existing) {
|
|
37
|
+
try { await fs.rename(staging, lock); return; }
|
|
38
|
+
catch (error) { if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error; }
|
|
39
|
+
}
|
|
40
|
+
if (await recoverDead(lock)) continue;
|
|
41
|
+
if (Date.now() >= deadline) fail('ENTROROVE_LOCK_BUSY', 'budget is held by an active process');
|
|
42
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
43
|
+
}
|
|
44
|
+
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
|
45
|
+
}
|
|
46
|
+
export async function withBudgetLock(file, action) {
|
|
47
|
+
const lock = `${file}.lock`; const token = randomUUID();
|
|
48
|
+
await acquire(lock, token);
|
|
49
|
+
try { return await action(); }
|
|
50
|
+
finally {
|
|
51
|
+
const current = await owner(lock);
|
|
52
|
+
if (!current || current.token !== token || current.pid !== process.pid) fail('ENTROROVE_LOCK_CHANGED', 'budget lock ownership changed during the operation');
|
|
53
|
+
await fs.rm(lock, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fail, sha } from './local-budget-grants.mjs';
|
|
6
|
+
import { budgetReadBuffer } from './local-budget.mjs';
|
|
7
|
+
import { budgetLocation, loadBudget, privateDirectory } from './local-budget-store.mjs';
|
|
8
|
+
|
|
9
|
+
const readContext = new AsyncLocalStorage(), projectBuffers = new WeakMap();
|
|
10
|
+
export async function assertBudgetReadContext(input) {
|
|
11
|
+
const binding = readContext.getStore(); if (!binding) return;
|
|
12
|
+
let home; let root;
|
|
13
|
+
try { home = await fs.realpath(input.home ?? os.homedir()); root = await fs.realpath(input.repositoryRoot); } catch {}
|
|
14
|
+
if (input.flowId !== binding.flowId || home !== binding.home || root !== binding.root) fail('ENTROROVE_BUDGET_BINDING_REQUIRED', 'source reading cannot replace its active runtime cloud task binding');
|
|
15
|
+
}
|
|
16
|
+
export async function withBudgetReadContext(input, action) {
|
|
17
|
+
await assertBudgetReadContext(input);
|
|
18
|
+
if (typeof input.repositoryRoot !== 'string' || typeof input.flowId !== 'string' || !await readHome(input)) return action();
|
|
19
|
+
const location = await budgetLocation(input); const loaded = await loadBudget(location, true);
|
|
20
|
+
if (!loaded?.state.enforced) return action();
|
|
21
|
+
const home = await fs.realpath(input.home ?? os.homedir());
|
|
22
|
+
return readContext.run(Object.freeze({ flowId: location.flowId, home, root: location.root }), action);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readHome(input) {
|
|
26
|
+
const supplied = input.home ?? os.homedir();
|
|
27
|
+
if (typeof supplied !== 'string' || !path.isAbsolute(supplied)) fail('ENTROROVE_HOME_INVALID', 'home must be absolute');
|
|
28
|
+
try {
|
|
29
|
+
const stat = await fs.lstat(supplied);
|
|
30
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) fail('ENTROROVE_HOME_INVALID', 'home must be a real directory');
|
|
31
|
+
return await fs.realpath(supplied);
|
|
32
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function enforcedBudgets(input) {
|
|
36
|
+
const home = await readHome(input); if (!home) return [];
|
|
37
|
+
const root = await fs.realpath(input.repositoryRoot);
|
|
38
|
+
const base = path.join(home, '.entrorove', 'budgets', sha(root).slice(0, 32));
|
|
39
|
+
let entries;
|
|
40
|
+
try { entries = await fs.readdir(base, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
41
|
+
const result = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) fail('ENTROROVE_STATE_INVALID', 'budget directory must be a real directory');
|
|
44
|
+
const location = await budgetLocation({ ...input, flowId: entry.name });
|
|
45
|
+
const loaded = await loadBudget(location, true);
|
|
46
|
+
if (loaded?.state.enforced) result.push(entry.name);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export async function budgetBindingForRead(input) {
|
|
51
|
+
await assertBudgetReadContext(input);
|
|
52
|
+
if (!await readHome(input)) return null;
|
|
53
|
+
if (typeof input.flowId === 'string') {
|
|
54
|
+
const selected = await loadBudget(await budgetLocation(input), true);
|
|
55
|
+
if (selected) return input;
|
|
56
|
+
}
|
|
57
|
+
const budgets = await enforcedBudgets(input);
|
|
58
|
+
if (!budgets.length) return null;
|
|
59
|
+
if (typeof input.flowId !== 'string' || !budgets.includes(input.flowId)) fail('ENTROROVE_BUDGET_BINDING_REQUIRED', 'an existing sandbox budget requires its original cloud flow binding');
|
|
60
|
+
return { ...input, flowId: input.flowId };
|
|
61
|
+
}
|
|
62
|
+
export async function assertBudgetRunnerAllowed(input) {
|
|
63
|
+
const binding = await budgetBindingForRead(input);
|
|
64
|
+
if (!binding) return;
|
|
65
|
+
const loaded = await loadBudget(await budgetLocation(binding));
|
|
66
|
+
if (loaded.state.enforced) fail('ENTROROVE_BUDGET_RUNNER_UNSUPPORTED', 'this process runner cannot enforce source read quotas; use a controlled read adapter');
|
|
67
|
+
}
|
|
68
|
+
export async function assertDependencyReadAllowed(input) {
|
|
69
|
+
const location = await budgetLocation(input);
|
|
70
|
+
const file = path.resolve(location.directory, '..', '..', '..', 'coordination', location.repositoryDigest, input.flowId, 'state.json');
|
|
71
|
+
let state; let handle;
|
|
72
|
+
try {
|
|
73
|
+
await privateDirectory(path.dirname(file), false);
|
|
74
|
+
handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
75
|
+
if (!(await handle.stat()).isFile()) fail('ENTROROVE_LEASE_INVALID', 'coordination state must be a regular file');
|
|
76
|
+
state = JSON.parse(await handle.readFile({ encoding: 'utf8' }));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error.code === 'ENOENT') return;
|
|
79
|
+
if (error instanceof SyntaxError || error.code === 'ELOOP') fail('ENTROROVE_LEASE_INVALID', 'coordination state is invalid');
|
|
80
|
+
throw error;
|
|
81
|
+
} finally { await handle?.close(); }
|
|
82
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)
|
|
83
|
+
|| state.workers !== undefined && (!Array.isArray(state.workers) || state.workers.some(worker => !worker || typeof worker !== 'object' || Array.isArray(worker)))
|
|
84
|
+
|| state.events !== undefined && (!Array.isArray(state.events) || state.events.some(event => !event || typeof event !== 'object' || Array.isArray(event)))) {
|
|
85
|
+
fail('ENTROROVE_LEASE_INVALID', 'coordination state must contain an object and valid worker/event arrays');
|
|
86
|
+
}
|
|
87
|
+
if (state.workers?.some(worker => ['waiting', 'dependency-wait'].includes(worker.status) && !Number.isFinite(Date.parse(worker.expiresAt)))) fail('ENTROROVE_LEASE_INVALID', 'dependency worker wait requires a bounded local expiry');
|
|
88
|
+
if (state.flowId !== input.flowId || state.repositoryDigest !== location.repositoryDigest) fail('ENTROROVE_LEASE_INVALID', 'coordination lease does not match this budget');
|
|
89
|
+
const waiting = state.workers?.some(worker => ['waiting', 'dependency-wait'].includes(worker.status) && Date.parse(worker.expiresAt) > Date.now());
|
|
90
|
+
let eventsWaiting = false;
|
|
91
|
+
for (const event of state.events ?? []) {
|
|
92
|
+
const expiry = event.expiresAt ? Date.parse(event.expiresAt) : Date.parse(event.at) + 300000;
|
|
93
|
+
if (event.event === 'dependency-wait' && !Number.isFinite(expiry)) fail('ENTROROVE_LEASE_INVALID', 'dependency wait requires a bounded local expiry');
|
|
94
|
+
if (event.event === 'dependency-wait' && expiry > Date.now()) eventsWaiting = true;
|
|
95
|
+
if (['dependency-ready', 'dependency-resumed', 'dependency-complete'].includes(event.event)) eventsWaiting = false;
|
|
96
|
+
}
|
|
97
|
+
if (waiting || eventsWaiting) fail('ENTROROVE_DEPENDENCY_WAIT', 'an active local dependency lease blocks source reading');
|
|
98
|
+
}
|
|
99
|
+
export async function readBudgetedProjectFile(input, target, options = {}) {
|
|
100
|
+
const binding = await budgetBindingForRead(input);
|
|
101
|
+
if (binding) {
|
|
102
|
+
const root = await fs.realpath(input.repositoryRoot);
|
|
103
|
+
const sourcePath = options.sourcePath ?? path.relative(root, target).split(path.sep).join('/');
|
|
104
|
+
const before = await fs.lstat(target, { bigint: true });
|
|
105
|
+
const result = await budgetReadBuffer({ ...binding, path: sourcePath }, { target });
|
|
106
|
+
const after = await fs.lstat(target, { bigint: true });
|
|
107
|
+
if (!sameSourceStat(before, after)) fail('ENTROROVE_SCOPE_CHANGED', 'budgeted project source changed during its bound observation');
|
|
108
|
+
projectBuffers.set(result.content, Object.freeze({ home: await fs.realpath(binding.home ?? os.homedir()),
|
|
109
|
+
root, flowId: binding.flowId, target: await fs.realpath(target), path: sourcePath, stat: Object.freeze(after), digest: sha(result.content) }));
|
|
110
|
+
return options.encoding ? result.content.toString(options.encoding) : result.content;
|
|
111
|
+
}
|
|
112
|
+
const handle = await fs.open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
113
|
+
try { if (!(await handle.stat()).isFile()) fail('ENTROROVE_FILE_INVALID', 'source must be a regular file'); return await handle.readFile(options.encoding ? { encoding: options.encoding } : undefined); }
|
|
114
|
+
finally { await handle.close(); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sameSourceStat(before, after) {
|
|
118
|
+
return before.isFile() && after.isFile() && ['dev', 'ino', 'size', 'mode', 'mtimeNs', 'ctimeNs'].every(key => before[key] === after[key]);
|
|
119
|
+
}
|
|
120
|
+
export function copyBudgetedProjectBuffer(content) {
|
|
121
|
+
const copy = Buffer.from(content), original = projectBuffers.get(content);
|
|
122
|
+
if (original) projectBuffers.set(copy, original);
|
|
123
|
+
return copy;
|
|
124
|
+
}
|
|
125
|
+
export async function verifyBudgetedProjectBuffer(input, target, content) {
|
|
126
|
+
const original = projectBuffers.get(content); if (!original) return false;
|
|
127
|
+
await assertBudgetReadContext(input);
|
|
128
|
+
if (input.flowId !== original.flowId || await fs.realpath(input.repositoryRoot) !== original.root
|
|
129
|
+
|| await fs.realpath(input.home ?? os.homedir()) !== original.home || await fs.realpath(target) !== original.target
|
|
130
|
+
|| path.relative(original.root, target).split(path.sep).join('/') !== original.path
|
|
131
|
+
|| sha(content) !== original.digest || !sameSourceStat(original.stat, await fs.lstat(target, { bigint: true }))) {
|
|
132
|
+
fail('ENTROROVE_GIT_SOURCE_INVALID', 'working Git bytes changed from their private committed project read');
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
package/local-budget-renewal.mjs
CHANGED
|
@@ -1,37 +1,89 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import { withStateLock } from './local-state-lock.mjs';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { operationSchemasFor } from './local-contracts.mjs';
|
|
3
|
+
import { canonical, fail, identifier, normalizeScope, requireLiveBudget, sha, validatePolicy, verifyBudgetGrant } from './local-budget-grants.mjs';
|
|
4
|
+
import { withBudget, commitBudget } from './local-budget-store.mjs';
|
|
6
5
|
|
|
7
|
-
const
|
|
8
|
-
function
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
6
|
+
const LOCAL_SCHEMA = 'entrorove.local-execution/1.0';
|
|
7
|
+
export function consumeBudgetRenewal({ state, now = Date.now() }) {
|
|
8
|
+
requireLiveBudget(state, now);
|
|
9
|
+
const auto = state.autoRenew;
|
|
10
|
+
if (!state.enforced || state.grant.purpose === 'handoff-read' || auto?.status !== 'authorized') return state;
|
|
11
|
+
const elapsed = now - Date.parse(state.startedAt);
|
|
12
|
+
if (elapsed < state.maxDurationMs) return state;
|
|
13
|
+
const count = Math.floor((elapsed - state.maxDurationMs) / auto.policy.intervalMs) + 1;
|
|
14
|
+
if (auto.renewalsUsed + count > auto.policy.maxRenewals) return state;
|
|
15
|
+
const added = count * auto.policy.intervalMs;
|
|
16
|
+
const intervals = Array.from({ length: count }, (_, index) => ({ count: auto.renewalsUsed + index + 1,
|
|
17
|
+
reason: 'expired-budgeted-read', at: new Date(now).toISOString(), intervalMs: auto.policy.intervalMs,
|
|
18
|
+
cumulativeDurationMs: (auto.cumulativeDurationMs ?? 0) + (index + 1) * auto.policy.intervalMs,
|
|
19
|
+
deadlineAt: new Date(Date.parse(state.startedAt) + state.maxDurationMs + (index + 1) * auto.policy.intervalMs).toISOString() }));
|
|
20
|
+
return { ...state, maxDurationMs: state.maxDurationMs + added, autoRenew: { ...auto, renewalsUsed: auto.renewalsUsed + count,
|
|
21
|
+
cumulativeDurationMs: (auto.cumulativeDurationMs ?? 0) + added, lastRenewedAt: new Date(now).toISOString(), lastRenewalReason: 'expired-budgeted-read',
|
|
22
|
+
lastRenewalIntervals: count, intervals: [...(auto.intervals ?? []), ...intervals] } };
|
|
23
|
+
}
|
|
24
|
+
export async function budgetAutoRenewRequest(input) {
|
|
25
|
+
return withBudget(input, async (location, loaded) => {
|
|
26
|
+
const state = loaded.state; requireLiveBudget(state);
|
|
27
|
+
if (!state.enforced) fail('ENTROROVE_BUDGET_NOT_APPLICABLE', 'automatic renewal applies only to a cloud sandbox');
|
|
28
|
+
if (state.grant.purpose === 'handoff-read') fail('ENTROROVE_BUDGET_HANDOFF_FORBIDDEN', 'temporary handoff reads cannot authorize automatic renewal');
|
|
29
|
+
if (state.grant.purpose === 'scope-bootstrap') fail('ENTROROVE_BUDGET_TRANSITION_REQUIRED', 'candidate source reading must accept its scope before automatic renewal');
|
|
30
|
+
const scope = normalizeScope(input.scope); const policy = validatePolicy(input.policy); const requestId = identifier(input.requestId, 'requestId');
|
|
31
|
+
if (canonical(scope) !== canonical(state.scope)) fail('ENTROROVE_RENEWAL_SCOPE', 'renewal scope must match the original cloud task');
|
|
32
|
+
if (state.autoRenew) {
|
|
33
|
+
if (state.autoRenew.status === 'pending' && state.autoRenew.requestId === requestId && canonical(state.autoRenew.policy) === canonical(policy)) return renewalView(state.autoRenew, state);
|
|
34
|
+
fail('ENTROROVE_RENEWAL_EXISTS', 'the task already has an immutable renewal request or authorization');
|
|
35
|
+
}
|
|
36
|
+
const record = { requestId, scope, scopeDigest: sha(canonical(scope)), policy, status: 'pending', interactionId: `renew-${randomUUID()}`,
|
|
37
|
+
createdAt: new Date().toISOString(), expiresAt: new Date(Math.min(Date.now() + 300000, Date.parse(state.grant.expiresAt))).toISOString() };
|
|
38
|
+
await commitBudget(location, loaded, { ...state, autoRenew: record }, 'budget-renew-request', { requestId, policy });
|
|
39
|
+
return renewalView(record, state);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function renewalView(record, state) {
|
|
43
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'waiting', requestId: record.requestId, scope: record.scope, policy: record.policy,
|
|
44
|
+
authority: 'cloud-confirmation-required', interaction: { schemaVersion: 'confirm.interaction/1.0', interactionId: record.interactionId,
|
|
45
|
+
type: 'confirm', risk: 'high', question: `Authorize read-time renewal for ${record.scope.goal}; scope ${record.scope.allowedPaths.join(', ')}; interval ${record.policy.intervalMs}ms; at most ${record.policy.maxRenewals} intervals?`,
|
|
46
|
+
options: [{ id: 'approve', label: 'Approve' }, { id: 'reject', label: 'Reject' }], default: null, timeoutAction: 'wait', rememberable: false, batchable: false, expiresAt: record.expiresAt },
|
|
47
|
+
cloudRequest: { operation: 'budget-auto-renew-request', flowId: state.flowId, requestId: record.requestId,
|
|
48
|
+
input: { stepId: state.grant.stepId, previousGrantId: state.grant.grantId, revision: state.revision, policy: record.policy } } };
|
|
49
|
+
}
|
|
50
|
+
export async function budgetAutoRenew(input) {
|
|
51
|
+
return withBudget(input, async (location, loaded) => {
|
|
52
|
+
const state = loaded.state; requireLiveBudget(state); const pending = state.autoRenew;
|
|
53
|
+
if (state.grant.purpose === 'handoff-read') fail('ENTROROVE_BUDGET_HANDOFF_FORBIDDEN', 'temporary handoff reads cannot authorize automatic renewal');
|
|
54
|
+
if (state.grant?.purpose === 'scope-bootstrap') fail('ENTROROVE_BUDGET_TRANSITION_REQUIRED', 'candidate source reading must accept its scope before automatic renewal');
|
|
55
|
+
let grant;
|
|
56
|
+
try { grant = verifyBudgetGrant(input.grant?.kind === 'auto-renew' ? input.grant : input.confirmation?.grant, state.grant, 'auto-renew'); }
|
|
57
|
+
catch (error) { return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: error.code ?? 'ENTROROVE_CONFIRMATION_BINDING' }; }
|
|
58
|
+
const scope = normalizeScope(input.scope); const policy = validatePolicy(input.policy);
|
|
59
|
+
if (!pending || pending.status !== 'pending' || pending.requestId !== input.requestId || grant.requestId !== input.requestId
|
|
60
|
+
|| Date.parse(pending.expiresAt) <= Date.now() || canonical(scope) !== canonical(pending.scope) || canonical(policy) !== canonical(pending.policy)
|
|
61
|
+
|| canonical(grant.policy) !== canonical(policy) || grant.previousGrantId !== state.grant.grantId || grant.revision !== state.revision + 1
|
|
62
|
+
|| grant.expiresAt !== state.grant.expiresAt || canonical(grant.limits) !== canonical({ maxFiles: state.maxFiles, maxTokenEstimate: state.maxTokenEstimate, maxDurationMs: state.maxDurationMs })) {
|
|
63
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'blocked', allowed: false, code: 'ENTROROVE_CONFIRMATION_BINDING' };
|
|
64
|
+
}
|
|
65
|
+
const autoRenew = { ...pending, status: 'authorized', approvedAt: grant.issuedAt, grantId: grant.grantId, grant, renewalsUsed: 0, cumulativeDurationMs: 0 };
|
|
66
|
+
await commitBudget(location, loaded, { ...state, autoRenew, grant, revision: grant.revision }, 'budget-renew-authorized', { grantId: grant.grantId });
|
|
67
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', allowed: true, chainId: input.flowId, requestId: input.requestId, scope, policy, renewalsUsed: 0 };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export async function budgetAutoRenewStop(input) {
|
|
71
|
+
return withBudget(input, async (location, loaded) => {
|
|
72
|
+
if (!['revoked', 'completed'].includes(input.reason)) fail('ENTROROVE_RENEWAL_REASON', 'reason must be revoked or completed');
|
|
73
|
+
const state = loaded.state;
|
|
74
|
+
if (state.closedReason === 'completed' || state.autoRenew?.status === 'stopped' && input.reason === 'revoked') return stopView(state, false);
|
|
75
|
+
const cloudRequest = state.enforced ? { operation: input.reason === 'completed' ? 'budget-read-lease-close' : 'budget-auto-renew-stop', flowId: state.flowId,
|
|
76
|
+
input: { stepId: state.grant.stepId, leaseId: state.grant.leaseId, leaseGeneration: state.grant.leaseGeneration,
|
|
77
|
+
...(input.reason === 'completed' ? { reason: input.reason } : { grantId: state.grant.grantId, scopeDigest: state.grant.scopeDigest, reason: 'revoked' }) } } : null;
|
|
78
|
+
const autoRenew = { ...(state.autoRenew ?? {}), status: 'stopped', stoppedAt: new Date().toISOString(), stopReason: input.reason, cloudRequest };
|
|
79
|
+
const committed = await commitBudget(location, loaded, { ...state, autoRenew, closedReason: input.reason === 'completed' ? 'completed' : state.closedReason }, 'budget-renew-stop', { reason: input.reason });
|
|
80
|
+
return stopView(committed, true);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function stopView(state, stopped) {
|
|
84
|
+
return { schemaVersion: LOCAL_SCHEMA, status: 'verified', stopped, reason: state.autoRenew.stopReason,
|
|
85
|
+
renewalsUsed: state.autoRenew.renewalsUsed ?? 0,
|
|
86
|
+
...(state.autoRenew.cloudRequest ? { cloudClosePending: true, cloudRequest: state.autoRenew.cloudRequest } : {}) };
|
|
23
87
|
}
|
|
24
|
-
function scope(input) { if (!input.scope || typeof input.scope.goal !== 'string' || !Array.isArray(input.scope.allowedPaths) || !input.scope.allowedPaths.length) fail('ENTROROVE_RENEWAL_SCOPE', 'renewal scope must include goal and allowedPaths'); return { goal: input.scope.goal, allowedPaths: [...new Set(input.scope.allowedPaths)].sort() }; }
|
|
25
|
-
function policy(input) { if (!input.policy || !Number.isSafeInteger(input.policy.intervalMs) || input.policy.intervalMs < 1 || !Number.isSafeInteger(input.policy.maxRenewals) || input.policy.maxRenewals < 1 || input.policy.maxRenewals > 100) fail('ENTROROVE_RENEWAL_POLICY', 'renewal policy must have bounded intervalMs and maxRenewals'); return { intervalMs: input.policy.intervalMs, maxRenewals: input.policy.maxRenewals }; }
|
|
26
|
-
async function renewalRequestUnlocked(input) { const current = await load(input); const requested = scope(input); const renewalPolicy = policy(input); if (!input.requestId || typeof input.requestId !== 'string' || !ID.test(input.requestId)) fail('ENTROROVE_REQUEST_ID', 'requestId must be a safe identifier'); if (current.state.autoRenew?.status === 'authorized') fail('ENTROROVE_RENEWAL_EXISTS', 'this budget already has an immutable renewal authorization'); const record = { requestId: input.requestId, chainId: input.flowId, scope: requested, scopeDigest: digest(canonical(requested)), policy: renewalPolicy, status: 'pending', createdAt: new Date().toISOString() }; const state = { ...current.state, autoRenew: record }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'waiting', interaction: { schemaVersion: 'confirm.interaction/1.0', interactionId: `renew-${randomUUID()}`, risk: 'medium', question: `Allow automatic read-time renewal for chain ${input.flowId}?`, options: [{ id: 'approve', label: 'Approve' }, { id: 'reject', label: 'Reject' }], default: null, expiresAt: new Date(Date.now() + 300_000).toISOString(), callback: { operation: 'budget-auto-renew', requestId: input.requestId } }, requestId: input.requestId, scope: requested, policy: renewalPolicy }; }
|
|
27
|
-
export async function budgetAutoRenewRequest(input) { const current = await load(input); return withStateLock(current.file, () => renewalRequestUnlocked(input)); }
|
|
28
|
-
async function renewalUnlocked(input) { const current = await load(input); const pending = current.state.autoRenew; const requested = scope(input); const renewalPolicy = policy(input); const confirmation = input.confirmation; if (!pending || pending.requestId !== input.requestId || pending.status !== 'pending' || confirmation?.answer !== 'approve' || confirmation.requestId !== input.requestId || pending.scopeDigest !== digest(canonical(requested)) || canonical(pending.policy) !== canonical(renewalPolicy)) return { schemaVersion: 'entrorove.local-execution/1.0', status: 'blocked', allowed: false, code: 'ENTROROVE_CONFIRMATION_BINDING' }; const state = { ...current.state, autoRenew: { ...pending, status: 'authorized', approvedAt: new Date().toISOString(), renewalsUsed: 0 } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', allowed: true, chainId: input.flowId, requestId: input.requestId, scope: requested, policy: renewalPolicy, renewalsUsed: 0 }; }
|
|
29
|
-
export async function budgetAutoRenew(input) { const current = await load(input); return withStateLock(current.file, () => renewalUnlocked(input)); }
|
|
30
|
-
async function renewalStopUnlocked(input) { const current = await load(input); const reason = input.reason; if (!['revoked', 'completed'].includes(reason)) fail('ENTROROVE_RENEWAL_REASON', 'reason must be revoked or completed'); if (!current.state.autoRenew || current.state.autoRenew.status !== 'authorized') return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: false, reason }; const state = { ...current.state, autoRenew: { ...current.state.autoRenew, status: 'stopped', stoppedAt: new Date().toISOString(), stopReason: reason } }; await save(current.file, state); return { schemaVersion: 'entrorove.local-execution/1.0', status: 'verified', stopped: true, reason, renewalsUsed: state.autoRenew.renewalsUsed ?? 0 }; }
|
|
31
|
-
export async function budgetAutoRenewStop(input) { const current = await load(input); return withStateLock(current.file, () => renewalStopUnlocked(input)); }
|
|
32
88
|
export const LOCAL_RENEWAL_OPERATIONS = Object.freeze({ 'budget-auto-renew-request': budgetAutoRenewRequest, 'budget-auto-renew': budgetAutoRenew, 'budget-auto-renew-stop': budgetAutoRenewStop });
|
|
33
|
-
export const LOCAL_RENEWAL_SCHEMAS =
|
|
34
|
-
'budget-auto-renew-request': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'] },
|
|
35
|
-
'budget-auto-renew': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy', 'confirmation'] },
|
|
36
|
-
'budget-auto-renew-stop': { type: 'object', additionalProperties: false, required: ['repositoryRoot', 'flowId', 'reason'] },
|
|
37
|
-
});
|
|
89
|
+
export const LOCAL_RENEWAL_SCHEMAS = operationSchemasFor(LOCAL_RENEWAL_OPERATIONS);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonical, fail, sha } from './local-budget-grants.mjs';
|
|
4
|
+
|
|
5
|
+
async function scopeState(file, location) {
|
|
6
|
+
const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
7
|
+
try {
|
|
8
|
+
const stat = await handle.stat();
|
|
9
|
+
if (!stat.isFile() || stat.size > 8388608 || process.getuid && stat.uid !== process.getuid()
|
|
10
|
+
|| process.platform !== 'win32' && (stat.mode & 0o077)) fail('ENTROROVE_STATE_INVALID', 'scope cache binding must be a private regular state file');
|
|
11
|
+
let state;
|
|
12
|
+
try { state = JSON.parse(await handle.readFile('utf8')); } catch { fail('ENTROROVE_BUDGET_SCOPE', 'scope cache binding is invalid JSON'); }
|
|
13
|
+
if (!state || typeof state !== 'object' || Array.isArray(state) || state.schemaVersion !== 'entrorove.scope-state/1.0' || state.flowId !== location.flowId
|
|
14
|
+
|| state.repositoryDigest !== location.repositoryDigest || !Array.isArray(state.history)
|
|
15
|
+
|| state.revision !== state.history.length || !state.originalSnapshot || !Array.isArray(state.originalFacts?.files)
|
|
16
|
+
|| !state.originalFacts.files.every(file => file && typeof file.path === 'string')) fail('ENTROROVE_BUDGET_SCOPE', 'scope cache state does not match this read flow');
|
|
17
|
+
for (const [index, record] of state.history.entries()) {
|
|
18
|
+
if (!record || !Array.isArray(record.facts?.files) || !Array.isArray(record.facts?.importGraph?.nodes)
|
|
19
|
+
|| !Array.isArray(record.facts?.modules) || ![...record.facts.files, ...record.facts.importGraph.nodes].every(file => file && typeof file.path === 'string')
|
|
20
|
+
|| !record.facts.modules.every(module => module && (module.manifestPath === null || typeof module.manifestPath === 'string'))) fail('ENTROROVE_BUDGET_SCOPE', 'scope cache facts are invalid');
|
|
21
|
+
const core = Object.fromEntries(Object.entries(record).filter(([key]) => key !== 'recordDigest'));
|
|
22
|
+
if (record.revision !== index + 1 || record.previousDigest !== (state.history[index - 1]?.recordDigest ?? null)
|
|
23
|
+
|| record.recordDigest !== sha(canonical(core))) fail('ENTROROVE_BUDGET_SCOPE', 'scope cache history integrity failed');
|
|
24
|
+
}
|
|
25
|
+
return state;
|
|
26
|
+
} finally { await handle.close(); }
|
|
27
|
+
}
|
|
28
|
+
async function acceptedScopeCache(location, relative, actual, base) {
|
|
29
|
+
const scope = path.join(base, 'scope', location.repositoryDigest, location.flowId);
|
|
30
|
+
const parts = path.relative(scope, actual).split(path.sep);
|
|
31
|
+
if (!/^revision-[1-9][0-9]*-[0-9a-f-]{36}$/.test(parts[0]) || !['original', 'current'].includes(parts[1])
|
|
32
|
+
|| parts.slice(2).join('/') !== relative) return false;
|
|
33
|
+
const state = await scopeState(path.join(scope, 'state.json'), location);
|
|
34
|
+
const directory = path.join(scope, parts[0], parts[1]);
|
|
35
|
+
if (parts[1] === 'original') return state.originalSnapshot.directory === directory && state.originalFacts.files.some(file => file.path === relative && file.exists !== false);
|
|
36
|
+
const record = state.history.find(item => item.snapshotDirectory === directory);
|
|
37
|
+
if (!record) return false;
|
|
38
|
+
return [...record.facts.files, ...record.facts.importGraph.nodes,
|
|
39
|
+
...record.facts.modules.map(module => ({ path: module.manifestPath }))].some(file => file.path === relative && file.exists !== false);
|
|
40
|
+
}
|
|
41
|
+
export async function budgetSourceTarget(location, relative, target) {
|
|
42
|
+
const directory = await fs.realpath(path.dirname(target)); const actual = path.join(directory, path.basename(target));
|
|
43
|
+
if (actual === path.resolve(location.root, relative)) return actual;
|
|
44
|
+
const base = path.resolve(location.directory, '..', '..', '..');
|
|
45
|
+
const snapshot = path.join(base, 'snapshots', location.repositoryDigest, location.flowId);
|
|
46
|
+
const parts = path.relative(snapshot, actual).split(path.sep);
|
|
47
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(parts[0]) && parts.length >= 2 && parts.slice(1).join('/') === relative) return actual;
|
|
48
|
+
if (actual.startsWith(`${path.join(base, 'scope', location.repositoryDigest, location.flowId)}${path.sep}`)
|
|
49
|
+
&& await acceptedScopeCache(location, relative, actual, base)) return actual;
|
|
50
|
+
fail('ENTROROVE_BUDGET_SCOPE', 'source does not match its repository path or committed cache binding');
|
|
51
|
+
}
|