@entrorove/skill 0.1.23 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/AGREEMENT.md +6 -6
  2. package/CHANGELOG.md +5 -0
  3. package/SKILL.md +12 -4
  4. package/archguard-runtime-build.mjs +15 -0
  5. package/blueprint-criterion-runner.mjs +110 -0
  6. package/blueprint-criterion-wire.mjs +78 -0
  7. package/calctool-runtime-build.mjs +59 -0
  8. package/calctool-runtime-entry.mjs +92 -0
  9. package/cli.mjs +17 -82
  10. package/local-approved-plan.mjs +74 -15
  11. package/local-archguard-ast.mjs +371 -0
  12. package/local-archguard-checkpoint.mjs +123 -0
  13. package/local-archguard-components.mjs +177 -0
  14. package/local-archguard-contract.mjs +109 -0
  15. package/local-archguard-contracts.mjs +42 -0
  16. package/local-archguard-evaluate.mjs +120 -0
  17. package/local-archguard-language-ast.mjs +165 -0
  18. package/local-archguard-mutation-fence.mjs +28 -0
  19. package/local-archguard-operations.mjs +25 -0
  20. package/local-archguard-scope.mjs +64 -0
  21. package/local-archguard-signing.mjs +52 -0
  22. package/local-archguard-snapshot.mjs +126 -0
  23. package/local-archguard-state.mjs +200 -0
  24. package/local-archguard.mjs +74 -0
  25. package/local-budget-git-attributes.mjs +49 -0
  26. package/local-budget-git-loader.mjs +61 -0
  27. package/local-budget-git-native-attributes.mjs +81 -0
  28. package/local-budget-git-native-darwin.mjs +104 -0
  29. package/local-budget-git-native-linux.mjs +93 -0
  30. package/local-budget-git-native-reads.mjs +88 -0
  31. package/local-budget-git-native-request.mjs +53 -0
  32. package/local-budget-git-native-runtime.mjs +18 -0
  33. package/local-budget-git-native.c +313 -0
  34. package/local-budget-git-native.mjs +109 -0
  35. package/local-budget-git-outcome.mjs +33 -0
  36. package/local-budget-git-scratch-loader.mjs +158 -0
  37. package/local-budget-git-scratch.mjs +112 -0
  38. package/local-budget-git-system-attributes.mjs +44 -0
  39. package/local-budget-git.mjs +92 -0
  40. package/local-budget-grants.mjs +82 -0
  41. package/local-budget-handoff-completion.mjs +70 -0
  42. package/local-budget-handoff-contracts.mjs +53 -0
  43. package/local-budget-handoff.mjs +229 -0
  44. package/local-budget-lock.mjs +55 -0
  45. package/local-budget-reads.mjs +135 -0
  46. package/local-budget-renewal.mjs +86 -34
  47. package/local-budget-source-bindings.mjs +51 -0
  48. package/local-budget-store.mjs +131 -0
  49. package/local-budget-transition.mjs +46 -0
  50. package/local-budget.mjs +185 -0
  51. package/local-calctool-cli.mjs +22 -0
  52. package/local-calctool-contracts.mjs +42 -0
  53. package/local-calctool-runtime.mjs +10 -0
  54. package/local-calctool-storage.mjs +101 -0
  55. package/local-calctool.mjs +103 -0
  56. package/local-cli.mjs +49 -16
  57. package/local-contract-types.mjs +46 -0
  58. package/local-contracts.mjs +124 -0
  59. package/local-credential.mjs +101 -0
  60. package/local-decimal.mjs +85 -0
  61. package/local-evidence-verification.mjs +199 -0
  62. package/local-execution.mjs +115 -304
  63. package/local-final-gate16-bundle.mjs +21 -0
  64. package/local-final-gate16-checks.mjs +88 -0
  65. package/local-final-gate16-contract.mjs +15 -0
  66. package/local-final-gate16-plan.mjs +66 -0
  67. package/local-final-gate16-proof.mjs +75 -0
  68. package/local-final-gate16-release.mjs +17 -0
  69. package/local-final-gate16-state.mjs +70 -0
  70. package/local-final-gate16-worker.mjs +19 -0
  71. package/local-final-gate16.mjs +137 -0
  72. package/local-formula-ast.mjs +97 -0
  73. package/local-formula-engine.mjs +111 -0
  74. package/local-formula-tests.mjs +61 -0
  75. package/local-formula.mjs +25 -125
  76. package/local-integrity.mjs +22 -41
  77. package/local-invocations.mjs +107 -0
  78. package/local-ledger.mjs +2 -7
  79. package/local-merge-archguard-verification.d.mts +44 -0
  80. package/local-merge-archguard-verification.mjs +240 -0
  81. package/local-merge-archguard.mjs +138 -0
  82. package/local-merge-artifacts.mjs +86 -0
  83. package/local-merge-authority.mjs +89 -0
  84. package/local-merge-contracts.mjs +56 -0
  85. package/local-merge-controller.mjs +170 -0
  86. package/local-merge-head.mjs +56 -0
  87. package/local-merge-journal.mjs +77 -0
  88. package/local-merge-mutation.mjs +97 -0
  89. package/local-merge-rollback.mjs +126 -0
  90. package/local-merge-snapshot-rollback.mjs +26 -0
  91. package/local-merge-storage.mjs +103 -0
  92. package/local-merge-targets.mjs +47 -0
  93. package/local-merge-transaction.mjs +133 -0
  94. package/local-mutation-audit.mjs +34 -0
  95. package/local-mutation-authority.mjs +96 -0
  96. package/local-mutation-batch-receipt.mjs +84 -0
  97. package/local-mutation-batch.mjs +143 -0
  98. package/local-mutation-binding.mjs +86 -0
  99. package/local-mutation-files.mjs +245 -0
  100. package/local-mutation-pass-store.mjs +63 -0
  101. package/local-mutation-snapshot.mjs +200 -0
  102. package/local-mutation-wire.mjs +89 -0
  103. package/local-probe.mjs +133 -11
  104. package/local-scope-candidate.mjs +28 -0
  105. package/local-scope-context.mjs +28 -0
  106. package/local-scope-files.mjs +106 -0
  107. package/local-scope-git-attributes.mjs +200 -0
  108. package/local-scope-git-comparison.mjs +8 -0
  109. package/local-scope-git-history.mjs +72 -0
  110. package/local-scope-git-reads.mjs +66 -0
  111. package/local-scope-git.mjs +124 -0
  112. package/local-scope-repository.mjs +249 -0
  113. package/local-scope-signature.mjs +70 -0
  114. package/local-scope-snapshot.mjs +39 -0
  115. package/local-scope-state.mjs +204 -0
  116. package/local-scope-workspaces.mjs +83 -0
  117. package/local-swarm-authority.mjs +143 -0
  118. package/local-swarm-baseline.mjs +32 -0
  119. package/local-swarm-completion.mjs +102 -0
  120. package/local-swarm-contracts.mjs +47 -0
  121. package/local-swarm-mutation.mjs +142 -0
  122. package/local-swarm-priority.mjs +237 -0
  123. package/local-swarm-state.mjs +102 -0
  124. package/local-swarm-storage.mjs +135 -0
  125. package/local-swarm.mjs +179 -0
  126. package/local-task-handoff-completion-wire.mjs +71 -0
  127. package/local-task-handoff-contracts.mjs +66 -0
  128. package/local-task-handoff-fence.mjs +478 -0
  129. package/local-task-handoff-observation.mjs +38 -0
  130. package/local-task-handoff-overlay-wire.mjs +94 -0
  131. package/local-task-handoff-read-authority.mjs +236 -0
  132. package/local-task-handoff-storage.mjs +73 -0
  133. package/local-task-handoff-write-archive.mjs +80 -0
  134. package/local-task-handoff-write-constraints.mjs +66 -0
  135. package/local-task-handoff-write-receipts.mjs +99 -0
  136. package/local-task-handoff-write-wire.mjs +99 -0
  137. package/local-task-routing.mjs +202 -22
  138. package/manifest.json +1 -1
  139. package/package.json +153 -3
  140. package/release-state.json +153 -17
  141. package/release.mjs +238 -4
  142. package/releases.json +6 -0
  143. package/remote-client.mjs +127 -36
  144. package/remote-transport.mjs +80 -0
  145. package/review-client.mjs +38 -0
  146. package/runtime/archguard-runtime-v7.0.44.mjs +314 -0
  147. package/runtime/calctool-runtime-v7.0.43.mjs +1887 -0
  148. package/runtime/git-native-darwin-v1.mjs +5 -0
  149. package/runtime/git-native-linux-v1.mjs +5 -0
  150. package/task-message-cli.mjs +47 -0
  151. package/task-message-entry.mjs +159 -0
  152. package/workflow-local-client.mjs +35 -0
  153. package/workflow-local-contracts.mjs +58 -0
  154. package/workflow-local-request.mjs +53 -0
  155. package/workflow-local-runner.mjs +350 -0
@@ -0,0 +1,478 @@
1
+ import { createPublicKey, randomUUID, sign, verify } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { withStateLock } from './local-state-lock.mjs';
6
+ import { json, atomic, context, readRegistry } from './local-task-handoff-storage.mjs';
7
+ import { HANDOFF_CANDIDATE_FIELDS, canonicalHandoffSignature, overlayAuthority, receiverClaim, receiverRestoration } from './local-task-handoff-overlay-wire.mjs';
8
+ import { completionChallenge, completionReceipt, completionReceiptCore } from './local-task-handoff-completion-wire.mjs';
9
+ import { loadTaskHandoffSigner as signer, taskHandoffFingerprints as fingerprints } from './local-task-handoff-observation.mjs';
10
+ import { newHandoffWriteArchive, verifyTaskHandoffWriteArchives, handoffWriteCompletionBatch } from './local-task-handoff-write-archive.mjs';
11
+
12
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
13
+ const SHA = /^[0-9a-f]{64}$/;
14
+ const STATE = 'entrorove.task-handoff-local-state/1.0';
15
+ const CHALLENGE = 'entrorove.task-handoff-challenge/1.0';
16
+ const RESUME = 'entrorove.task-handoff-resume-authorization/1.0';
17
+ const EXECUTION = 'entrorove.task-execution-epoch-authority/1.0';
18
+ const EXECUTION_FIELDS = ['schemaVersion', 'authorityId', 'attemptNonce', 'taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest',
19
+ 'leaseId', 'generation', 'epoch', 'status', 'runnerKeyId', 'localRepositoryDigest', 'issuedAt', 'expiresAt'];
20
+ const CHALLENGE_FIELDS = ['schemaVersion', 'purpose', 'challengeId', 'handoffId', 'messageRequestId', 'taskId', 'flowId', 'ownerId',
21
+ 'taskDigest', 'chainDigest', 'checkpointReceiptId', 'checkpointDigest', 'originalScopeDigest', 'localRepositoryDigest',
22
+ 'repositoryFactsDigest', 'scopeSnapshotDigest', 'paths', 'expectedEpoch', 'runnerKeyId', 'issuedAt', 'expiresAt'];
23
+ const RESUME_FIELDS = ['schemaVersion', 'handoffId', 'taskId', 'flowId', 'ownerId', 'localRepositoryDigest', 'epoch',
24
+ 'baselineReceiptDigest', 'baselineChallengeId', 'taskDigest', 'chainDigest', 'originalScopeDigest', 'scopeSnapshotDigest', 'issuedAt', 'expiresAt'];
25
+ function fail(message, code = 'ENTROROVE_HANDOFF_FENCE') { const error = new Error(message); error.code = code; throw error; }
26
+ function exact(value, fields, label) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value)
28
+ || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) fail(`${label} fields are invalid`);
29
+ return value;
30
+ }
31
+ function id(value, label) { if (typeof value !== 'string' || !ID.test(value)) fail(`${label} identifier is invalid`); return value; }
32
+ function epoch(value) { if (!Number.isSafeInteger(value) || value < 0) fail('handoff epoch is invalid'); return value; }
33
+ function safePath(value) {
34
+ if (typeof value !== 'string' || !value || value !== value.normalize('NFC') || path.isAbsolute(value) || /^[A-Za-z]:/.test(value)
35
+ || /[\\\u0000-\u001f\u007f]/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..')) fail('handoff path is unsafe');
36
+ return value;
37
+ }
38
+ function pinned(name) {
39
+ try {
40
+ const encoded = process.env[name]; if (!encoded) fail(`pinned runner/authority key ${name} is required`);
41
+ const der = Buffer.from(encoded, 'base64'); const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
42
+ if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded) fail('pinned key is not canonical Ed25519');
43
+ return { key, keyId: sha256(der) };
44
+ } catch { fail(`pinned runner/authority key ${name} is invalid`); }
45
+ }
46
+ function authority(value, fields, schema) {
47
+ const { authorityReceipt: receipt, ...payload } = exact(value, [...fields, 'authorityReceipt'], 'handoff authority');
48
+ exact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature'], 'handoff authority receipt');
49
+ const bytes = Buffer.from(canonical(payload)); const key = pinned('ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY');
50
+ if (payload.schemaVersion !== schema || bytes.length > 64 * 1024 || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0'
51
+ || receipt.keyId !== key.keyId || receipt.payloadDigest !== sha256(bytes) || typeof receipt.signature !== 'string'
52
+ || !verify(null, bytes, key.key, canonicalHandoffSignature(receipt.signature))) fail('handoff authority schema/signature binding is invalid');
53
+ return payload;
54
+ }
55
+ function executionAuthority(value) {
56
+ const takeover = Object.hasOwn(value ?? {}, 'handoffId') || Object.hasOwn(value ?? {}, 'writePassDigest');
57
+ const payload = authority(value, takeover ? [...EXECUTION_FIELDS, 'handoffId', 'writePassDigest'] : EXECUTION_FIELDS, EXECUTION);
58
+ if (takeover && (typeof payload.writePassDigest !== 'string' || !SHA.test(payload.writePassDigest))) fail('takeover authority requires its exact signed write pass digest');
59
+ if (takeover) id(payload.handoffId, 'handoffId');
60
+ return payload;
61
+ }
62
+ function lifetime(value, live = true) {
63
+ const start = Date.parse(value.issuedAt); const end = Date.parse(value.expiresAt);
64
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > Date.now() || end <= start || end - start > 90_000
65
+ || (live && end <= Date.now())) fail('handoff authority is expired or has invalid issuance');
66
+ }
67
+ function fresh(ctx) { return { schemaVersion: STATE, flowId: ctx.flowId, localRepositoryDigest: ctx.repo, status: 'active', epoch: 0,
68
+ activities: [], revokedPassIds: [], revokedLeaseIds: [], releaseReceipt: null, baselineReceipt: null, resumeAuthorization: null,
69
+ receiverClaim: null, receiverRestoration: null, receiverCompletion: null, executionAttempts: [], writeArchive: null, writeArchiveHistory: [] }; }
70
+ function receiptSignature(receipt, ctx) {
71
+ const key = pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY'); const { signature, ...core } = receipt ?? {};
72
+ if (receipt?.schemaVersion !== 'entrorove.task-handoff-idle-receipt/1.0' || receipt.keyId !== key.keyId || receipt.flowId !== ctx.flowId
73
+ || receipt.localRepositoryDigest !== ctx.repo || !verify(null, Buffer.from(canonical(core)), key.key, canonicalHandoffSignature(signature))) fail('handoff state has no trusted release/baseline receipt');
74
+ return receipt;
75
+ }
76
+ function executionAttempts(state, registration, ctx) {
77
+ const leases = new Set(), nonces = new Set();
78
+ for (const item of state.executionAttempts) {
79
+ exact(item, item.status === 'used' ? ['authority', 'status', 'executionHome', 'executionId'] : ['authority', 'status', 'executionHome'], 'execution attempt');
80
+ const value = executionAuthority(item.authority); lifetime(value, false); epoch(value.epoch);
81
+ if (!['ready', 'used', 'revoked'].includes(item.status) || value.status !== 'active' || value.flowId !== ctx.flowId
82
+ || value.localRepositoryDigest !== ctx.repo || value.epoch > state.epoch || !Number.isSafeInteger(value.generation) || value.generation < 1
83
+ || Date.parse(value.expiresAt) - Date.parse(value.issuedAt) > 30_000
84
+ || typeof item.executionHome !== 'string' || !path.isAbsolute(item.executionHome) || path.resolve(item.executionHome) !== item.executionHome
85
+ || ['taskId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId'].some(key => value[key] !== registration[key])) fail('persisted execution attempt lost its trusted original binding or execution home');
86
+ const lease = `${id(value.leaseId, 'leaseId')}:${value.generation}`, nonce = id(value.attemptNonce, 'attemptNonce');
87
+ if (leases.has(lease) || nonces.has(nonce)) fail('persisted execution attempt is duplicated');
88
+ if (item.status === 'used') id(item.executionId, 'executionId');
89
+ leases.add(lease); nonces.add(nonce);
90
+ }
91
+ }
92
+ function receiverCurrent(state) {
93
+ return Boolean(state.receiverClaim && (!state.receiverRestoration || !state.releaseReceipt
94
+ || state.releaseReceipt.epoch <= state.receiverRestoration.epoch));
95
+ }
96
+ async function read(ctx) {
97
+ const registration = (await readRegistry(ctx.registry)).bindings[ctx.registryKey];
98
+ if (!registration) fail('flow is uninitialized at its registered runner; fresh cloud authority is required');
99
+ let state; try { state = await json(ctx.file, 9 * 1024 * 1024); } catch (error) { if (error.code === 'ENOENT') fail('registered handoff state is missing; explicit cloud recovery is required'); throw error; }
100
+ exact(state, Object.keys(fresh(ctx)), 'handoff state'); epoch(state.epoch);
101
+ if (state.schemaVersion !== STATE || state.flowId !== ctx.flowId || state.localRepositoryDigest !== ctx.repo
102
+ || !['active', 'paused'].includes(state.status) || !Array.isArray(state.activities) || state.activities.length > 128
103
+ || !Array.isArray(state.revokedPassIds) || !Array.isArray(state.revokedLeaseIds)
104
+ || !Array.isArray(state.executionAttempts) || state.executionAttempts.length > 1024) fail('handoff state binding is invalid');
105
+ executionAttempts(state, registration, ctx);
106
+ await verifyTaskHandoffWriteArchives(state, ctx);
107
+ if (state.receiverClaim) {
108
+ const { pass } = receiverClaim(state.receiverClaim, ctx, false);
109
+ const restored = state.receiverRestoration && receiverRestoration(state.receiverRestoration, state.receiverClaim, ctx, false);
110
+ const completion = state.receiverCompletion && completionReceipt(state.receiverCompletion, state.receiverClaim, ctx);
111
+ if (completion?.writeCheckpointReceipts && (canonical(completion.writeCheckpointReceipts) !== canonical(state.writeArchive.receipts)
112
+ || completion.writeCheckpointReceiptsDigest !== sha256(canonical(state.writeArchive.receipts)))) fail('completion proof dropped or changed trusted write history');
113
+ if (restored && (!completion || restored.completionIdleReceiptDigest !== sha256(canonical(completion))
114
+ || restored.baselineReceiptDigest !== sha256(canonical(completion)))) fail('receiver restoration lost its actual local completion proof');
115
+ if ((receiverCurrent(state) && (state.status !== (completion && !restored ? 'paused' : 'active') || state.epoch !== (restored ? restored.epoch : pass.receiverEpoch)))
116
+ || pass.receiverTaskId !== registration.taskId || pass.ownerId !== registration.ownerId
117
+ || pass.taskDigest !== registration.taskDigest || pass.chainDigest !== registration.chainDigest) fail('receiver takeover lost its actual original runner registration');
118
+ }
119
+ if (!receiverCurrent(state) && state.epoch > 0) {
120
+ const receipt = receiptSignature(state.releaseReceipt, ctx);
121
+ if (receipt.epoch !== state.epoch || canonical(receipt.revokedPassIds) !== canonical(state.revokedPassIds)
122
+ || canonical(receipt.revokedLeaseIds) !== canonical(state.revokedLeaseIds)) fail('handoff state revocation binding changed');
123
+ if (state.baselineReceipt) receiptSignature(state.baselineReceipt, ctx);
124
+ if (state.status === 'active') {
125
+ const resumed = authority(state.resumeAuthorization, RESUME_FIELDS, RESUME); lifetime(resumed, false);
126
+ if (!state.baselineReceipt || resumed.baselineReceiptDigest !== sha256(canonical(state.baselineReceipt)) || resumed.epoch !== state.epoch
127
+ || ['flowId', 'localRepositoryDigest', 'handoffId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'originalScopeDigest'].some(key => resumed[key] !== receipt[key])) fail('handoff resume authorization binding is invalid');
128
+ }
129
+ } else if (!receiverCurrent(state) && (state.status !== 'active' || state.releaseReceipt || state.resumeAuthorization)) fail('initial handoff state cannot be paused or resumed');
130
+ if (registration.flowId !== ctx.flowId || registration.localRepositoryDigest !== ctx.repo
131
+ || registration.runnerKeyId !== pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId) fail('registered runner identity does not match the actual flow');
132
+ return state;
133
+ }
134
+ function active(state, input) {
135
+ if (state.status !== 'active') fail('source task is paused; mutation and new grants are revoked');
136
+ if (state.writeArchive?.pending) fail('unknown pending physical write prevents new activity');
137
+ if (state.writeArchive?.interrupted && !state.receiverRestoration) fail('three repeated native blocks interrupted the actual handoff; human direction is required');
138
+ if (epoch(input.epoch ?? 0) !== state.epoch || (input.passId && state.revokedPassIds.includes(input.passId))
139
+ || (input.leaseId && state.revokedLeaseIds.includes(input.leaseId))) fail('pass/lease epoch is revoked by the actual handoff');
140
+ if (state.receiverClaim && !state.receiverRestoration) {
141
+ const { pass, lease } = receiverClaim(state.receiverClaim, { flowId: state.flowId, repo: state.localRepositoryDigest });
142
+ if (input.passId !== pass.passId || input.leaseId !== lease.leaseId || input.handoffId !== pass.handoffId) fail('temporary receiver scope only accepts its fresh takeover pass and lease');
143
+ const paths = input.targetPath ? [input.targetPath] : input.paths;
144
+ if (!Array.isArray(paths) || !paths.length || paths.some(target => !pass.paths.includes(target))) fail('temporary takeover write is outside its exact path scope');
145
+ }
146
+ if (state.receiverRestoration) {
147
+ const paths = input.targetPath ? [input.targetPath] : input.paths;
148
+ if (paths && paths.some(target => !state.receiverRestoration.paths.includes(target))) fail('restored receiver can only mutate its original scope paths');
149
+ }
150
+ }
151
+ export async function readTaskHandoffFence(input) { return read(await context(input)); }
152
+ async function mutationAuthority(ctx, state) {
153
+ const { assertTaskHandoffReadMutationAuthority } = await import('./local-task-handoff-read-authority.mjs');
154
+ await assertTaskHandoffReadMutationAuthority(ctx, state);
155
+ }
156
+ export async function assertTaskHandoffActive(input) {
157
+ const ctx = await context(input), state = await read(ctx); active(state, input);
158
+ await mutationAuthority(ctx, state); return state;
159
+ }
160
+ export async function withTaskHandoffWriteFence(input, callback) {
161
+ const ctx = await context(input, true);
162
+ return withStateLock(ctx.file, async () => { const state = await read(ctx); active(state, input);
163
+ await mutationAuthority(ctx, state);
164
+ return callback(state, { context: ctx, state, persist: async updated => {
165
+ await verifyTaskHandoffWriteArchives(updated, ctx); await atomic(ctx.file, updated);
166
+ } }); });
167
+ }
168
+ export async function withTaskHandoffReadFence(input, callback) {
169
+ const ctx = await context(input, true);
170
+ return withStateLock(ctx.file, async () => {
171
+ const state = await read(ctx); active(state, input);
172
+ return callback(state, { authorizeMutation: () => mutationAuthority(ctx, state) });
173
+ });
174
+ }
175
+ export async function withTaskHandoffIssueFence(input, callback) {
176
+ const ctx = await context(input, true);
177
+ return withStateLock(ctx.file, async () => { const state = await read(ctx); active(state, { ...input, epoch: state.epoch });
178
+ await mutationAuthority(ctx, state); return callback(state); });
179
+ }
180
+ export function taskHandoffSnapshotBinding(state) {
181
+ const digest = receiverCurrent(state) ? state.receiverRestoration?.baselineReceiptDigest ?? state.receiverClaim.challenge.baselineReceiptDigest
182
+ : state.baselineReceipt ? sha256(canonical(state.baselineReceipt)) : state.releaseReceipt ? sha256(canonical(state.releaseReceipt)) : null;
183
+ return { handoffEpoch: epoch(state.epoch), handoffBaselineReceiptDigest: digest };
184
+ }
185
+ export async function readTaskHandoffSnapshotBinding(input) {
186
+ if (!process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY) return {};
187
+ const ctx = await context(input); const registry = await readRegistry(ctx.registry);
188
+ if (input.handoffCandidate) {
189
+ const candidate = overlayAuthority(input.handoffCandidate, HANDOFF_CANDIDATE_FIELDS, 'entrorove.task-handoff-scope-challenge/1.0');
190
+ const current = registry.bindings[ctx.registryKey] ? await read(ctx) : fresh(ctx);
191
+ if (candidate.flowId !== ctx.flowId || candidate.localRepositoryDigest !== ctx.repo || candidate.stepId !== input.stepId
192
+ || candidate.runnerKeyId !== pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId || candidate.receiverEpoch !== current.epoch + 1
193
+ || canonical(candidate.paths) !== canonical(input.paths)) fail('candidate snapshot requires the exact fresh cloud receiver scope challenge');
194
+ return { handoffEpoch: candidate.receiverEpoch, handoffBaselineReceiptDigest: candidate.baselineReceiptDigest };
195
+ }
196
+ if (!registry.bindings[ctx.registryKey]) return {};
197
+ const state = await read(ctx); return state.epoch > 0 ? taskHandoffSnapshotBinding(state) : {};
198
+ }
199
+ export function assertTaskHandoffSnapshotBinding(state, snapshot) {
200
+ const current = taskHandoffSnapshotBinding(state);
201
+ if (state.epoch > 0 && (snapshot?.handoffEpoch !== current.handoffEpoch
202
+ || snapshot?.handoffBaselineReceiptDigest !== current.handoffBaselineReceiptDigest)) fail('fresh snapshot must bind the confirmed handoff baseline and current epoch');
203
+ if (state.receiverRestoration && (!Array.isArray(snapshot?.paths)
204
+ || snapshot.paths.some(target => !state.receiverRestoration.paths.includes(target)))) fail('fresh snapshot cannot expand the restored original receiver scope');
205
+ return current;
206
+ }
207
+ export async function consumeTaskHandoffExecutionAuthority(input) {
208
+ const ctx = await context(input, true); const value = executionAuthority(input.authority);
209
+ const executionHome = await fs.realpath(ctx.home);
210
+ lifetime(value);
211
+ if (Date.parse(value.expiresAt) - Date.parse(value.issuedAt) > 30_000 || value.status !== 'active' || value.flowId !== ctx.flowId
212
+ || value.localRepositoryDigest !== ctx.repo || value.attemptNonce !== input.attemptNonce || value.leaseId !== input.leaseId
213
+ || value.generation !== input.generation || value.taskId !== input.taskId || value.runnerKeyId !== pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId) fail('fresh execution authority does not match this actual nonce, lease and registered runner');
214
+ epoch(value.epoch); id(value.authorityId, 'authorityId'); id(value.attemptNonce, 'attemptNonce');
215
+ return withStateLock(ctx.registry, async () => {
216
+ const registry = await readRegistry(ctx.registry); const nonce = `execution:${value.runnerKeyId}:${value.attemptNonce}`;
217
+ const consumed = registry.consumed.includes(nonce);
218
+ const registered = registry.bindings[ctx.registryKey];
219
+ if (registered && ['taskId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId'].some(key => registered[key] !== value[key])) fail('execution authority changed its original registered task binding');
220
+ return withStateLock(ctx.file, async () => {
221
+ let state;
222
+ if (registered) {
223
+ state = await read(ctx); const takeover = state.receiverClaim && !state.receiverRestoration ? state.receiverClaim.pass : null;
224
+ if (takeover ? value.handoffId !== takeover.handoffId || value.writePassDigest !== sha256(canonical(takeover))
225
+ : value.handoffId !== undefined) fail('execution authority lost its actual temporary takeover identity and signed pass binding');
226
+ active(state, { epoch: value.epoch, leaseId: value.leaseId, ...(takeover ? { passId: takeover.passId,
227
+ handoffId: takeover.handoffId, paths: takeover.paths } : {}) });
228
+ }
229
+ else { if (value.epoch !== 0 || value.handoffId !== undefined) fail('lost handoff history cannot be recreated from an active epoch authority'); state = fresh(ctx); }
230
+ if (consumed) {
231
+ const ready = state.executionAttempts.find(item => item.authority.leaseId === value.leaseId
232
+ && item.authority.generation === value.generation && item.authority.attemptNonce === value.attemptNonce);
233
+ if (!registered || ready?.status !== 'ready' || canonical(ready.authority) !== canonical(input.authority)) fail('consumed nonce may only recover its exact original ready authority; used, revoked or unknown attempts cannot revive');
234
+ if (ready.executionHome !== executionHome) fail('ready recovery requires its original actual execution home');
235
+ await idle(ctx, state);
236
+ return { epoch: state.epoch, baselineReceiptDigest: taskHandoffSnapshotBinding(state).handoffBaselineReceiptDigest };
237
+ }
238
+ const binding = Object.fromEntries(['taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId', 'localRepositoryDigest'].map(key => [key, value[key]]));
239
+ if (state.executionAttempts.some(item => item.authority.leaseId === value.leaseId && item.authority.generation === value.generation)) fail('original lease/generation already has an actual attempt; reconcile its outcome instead of spawning again');
240
+ if (state.executionAttempts.length >= 1024) fail('actual execution attempt registry is full; reconcile its retained history');
241
+ await atomic(ctx.registry, { ...registry, consumed: [...registry.consumed, nonce], bindings: { ...registry.bindings, [ctx.registryKey]: binding } });
242
+ await atomic(ctx.file, { ...state, executionAttempts: [...state.executionAttempts, { authority: input.authority, status: 'ready', executionHome }] });
243
+ return { epoch: state.epoch, baselineReceiptDigest: taskHandoffSnapshotBinding(state).handoffBaselineReceiptDigest };
244
+ });
245
+ });
246
+ }
247
+ export async function applyTaskHandoffReceiverClaim(input) {
248
+ const ctx = await context(input, true); const claim = { pass: input.pass, lease: input.lease, challenge: input.challenge };
249
+ const accepted = receiverClaim(claim, ctx); const { pass, lease, challenge } = accepted;
250
+ if (pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId !== pass.runnerKeyId) fail('receiver takeover runner is not the configured trusted runner');
251
+ return withStateLock(ctx.registry, () => withStateLock(ctx.file, async () => {
252
+ const registry = await readRegistry(ctx.registry); const registered = registry.bindings[ctx.registryKey];
253
+ const state = registered ? await read(ctx) : fresh(ctx);
254
+ if (state.receiverClaim && canonical(state.receiverClaim) === canonical(claim)) return state;
255
+ if (state.receiverClaim && !state.receiverRestoration) fail('receiver already has one active scoped takeover');
256
+ if (state.status !== 'active' || pass.receiverEpoch !== state.epoch + 1) fail('receiver takeover epoch must advance its actual original state exactly once');
257
+ if (registered && ['ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId'].some(key => registered[key] !== pass[key])) fail('receiver takeover changed its original task binding');
258
+ if (registered && registered.taskId !== pass.receiverTaskId) fail('receiver takeover belongs to another original task');
259
+ await idle(ctx, state);
260
+ if ((await fingerprints(ctx, pass.paths)).digest !== pass.scopeFingerprintDigest) fail('receiver takeover fingerprints changed from its fresh signed scope');
261
+ const manifest = await json(path.join(ctx.home, '.entrorove/snapshots', ctx.repo, ctx.flowId, pass.snapshotId, 'manifest.json'));
262
+ if (manifest.snapshotDigest !== pass.scopeSnapshotDigest || manifest.handoffEpoch !== pass.receiverEpoch
263
+ || manifest.handoffBaselineReceiptDigest !== challenge.baselineReceiptDigest) fail('receiver takeover requires its actual fresh candidate snapshot and release baseline binding');
264
+ const revoked = await revocations(ctx, state);
265
+ if (!registered) {
266
+ const binding = { taskId: pass.receiverTaskId, flowId: pass.flowId, ownerId: pass.ownerId, taskDigest: pass.taskDigest,
267
+ chainDigest: pass.chainDigest, runnerKeyId: pass.runnerKeyId, localRepositoryDigest: pass.localRepositoryDigest };
268
+ await atomic(ctx.registry, { ...registry, bindings: { ...registry.bindings, [ctx.registryKey]: binding } });
269
+ }
270
+ if (state.writeArchive?.pending) fail('unknown prior physical write prevents a new scoped takeover');
271
+ const writeArchive = newHandoffWriteArchive(claim, manifest, ctx);
272
+ const updated = { ...state, writeArchive, writeArchiveHistory: [...state.writeArchiveHistory, ...(state.writeArchive ? [state.writeArchive] : [])],
273
+ epoch: pass.receiverEpoch, revokedPassIds: revoked.passes, revokedLeaseIds: revoked.leases,
274
+ receiverClaim: claim, receiverRestoration: null, receiverCompletion: null,
275
+ executionAttempts: state.executionAttempts.map(item => item.status === 'ready' ? { ...item, status: 'revoked' } : item) };
276
+ await verifyTaskHandoffWriteArchives(updated, ctx); await atomic(ctx.file, updated); return updated;
277
+ }));
278
+ }
279
+ export async function restoreTaskHandoffReceiverScope(input) {
280
+ const ctx = await context(input, true);
281
+ return withStateLock(ctx.file, async () => {
282
+ const state = await read(ctx);
283
+ if (state.receiverRestoration && canonical(state.receiverRestoration) === canonical(input.authorization)) return state;
284
+ if (!state.receiverClaim || state.receiverRestoration || !state.receiverCompletion || state.status !== 'paused') fail('receiver requires its actual completed idle/revocation proof before restoring scope');
285
+ const restored = receiverRestoration(input.authorization, state.receiverClaim, ctx); await idle(ctx, state);
286
+ const proof = completionReceipt(state.receiverCompletion, state.receiverClaim, ctx);
287
+ if (!Array.isArray(proof.writeCheckpointReceipts)) fail('legacy completion receipt cannot restore live receiver write authority');
288
+ if (restored.completionIdleReceiptDigest !== sha256(canonical(proof)) || restored.baselineReceiptDigest !== sha256(canonical(proof))) fail('cloud restoration did not commit this actual local completion proof');
289
+ if ((await fingerprints(ctx, proof.paths)).digest !== proof.scopeFingerprintDigest) fail('actual completion fingerprint changed before confirmed receiver restoration');
290
+ const updated = { ...state, status: 'active', epoch: restored.epoch, revokedPassIds: [...new Set([...state.revokedPassIds, ...restored.revokedPassIds])].sort(),
291
+ revokedLeaseIds: [...new Set([...state.revokedLeaseIds, ...restored.revokedLeaseIds])].sort(), receiverRestoration: input.authorization };
292
+ await atomic(ctx.file, updated); return updated;
293
+ });
294
+ }
295
+ export async function observeTaskHandoffReceiverCompletion(input) {
296
+ const ctx = await context(input, true);
297
+ const budget = await import('./local-budget-handoff-completion.mjs');
298
+ if (!budget.isBudgetHandoffCompletionObservation(input)) return budget.observeBudgetedTaskHandoffReceiverCompletion(input);
299
+ return withStateLock(ctx.file, async () => {
300
+ const state = await read(ctx);
301
+ if (!state.receiverClaim || state.receiverRestoration) fail('completion requires its actual current receiver takeover');
302
+ const challenge = completionChallenge(input.challenge, state.receiverClaim, ctx, false);
303
+ if (state.receiverCompletion?.receipt.challengeId === challenge.challengeId) {
304
+ if (state.receiverCompletion.receipt.challengeDigest !== sha256(canonical(challenge))) fail('completion challenge replay binding changed');
305
+ return state.receiverCompletion.receipt;
306
+ }
307
+ lifetime(challenge);
308
+ if (state.receiverCompletion && Date.parse(state.receiverCompletion.receipt.expiresAt) > Date.now()) fail('completion is already locally paused; only its known expired challenge may renew');
309
+ const workflowStateDigest = await idle(ctx, state), observed = await fingerprints(ctx, challenge.paths);
310
+ const writeBatch = await handoffWriteCompletionBatch(state, ctx, observed.files);
311
+ const key = await signer(ctx, input.signerConfigPath, challenge.runnerKeyId), revoked = await revocations(ctx, state);
312
+ const revokedPassIds = [...new Set([...revoked.passes, challenge.passId])].sort();
313
+ const revokedLeaseIds = [...new Set([...revoked.leases, challenge.leaseId])].sort();
314
+ const core = completionReceiptCore(challenge, { receiptId: randomUUID(), nonce: randomUUID(), files: observed.files,
315
+ scopeFingerprintDigest: observed.digest, workflowStateDigest, revokedPassIds, revokedLeaseIds, ...writeBatch }, key.config.keyId, key.config.receiptTtlMs);
316
+ const receipt = { ...core, signature: sign(null, Buffer.from(canonical(core)), key.privateKey).toString('base64url') };
317
+ const proof = { challenge: input.challenge, receipt }; completionReceipt(proof, state.receiverClaim, ctx, true);
318
+ await atomic(ctx.file, { ...state, status: 'paused', receiverCompletion: proof, revokedPassIds, revokedLeaseIds,
319
+ executionAttempts: state.executionAttempts.map(item => item.status === 'ready' ? { ...item, status: 'revoked' } : item) });
320
+ return receipt;
321
+ });
322
+ }
323
+ export async function beginTaskHandoffExecution(input) {
324
+ const ctx = await context(input, true);
325
+ return withStateLock(ctx.file, async () => {
326
+ const state = await read(ctx); active(state, input); const executionId = id(input.executionId, 'executionId');
327
+ if (state.activities.some(item => item.executionId === executionId)) fail('execution already has an in-flight registration');
328
+ if (state.activities.length >= 128) fail('handoff in-flight registry is full');
329
+ const ready = state.executionAttempts.find(item => item.status === 'ready' && item.authority.leaseId === input.leaseId
330
+ && item.authority.generation === input.generation && item.authority.attemptNonce === input.attemptNonce);
331
+ if (!ready) fail('actual execution needs its ready fresh cloud lease/generation/nonce authority');
332
+ if (ready.executionHome !== await fs.realpath(ctx.home)) fail('actual execution requires its original ready authority execution home');
333
+ const payload = executionAuthority(ready.authority); lifetime(payload);
334
+ if (payload.epoch !== state.epoch || payload.flowId !== ctx.flowId || payload.localRepositoryDigest !== ctx.repo) fail('ready execution authority is revoked or outside this actual flow');
335
+ const activity = { executionId, stepId: id(input.stepId, 'stepId'), leaseId: id(input.leaseId, 'leaseId'), token: randomUUID(),
336
+ epoch: state.epoch, pid: process.pid, startedAt: new Date().toISOString() };
337
+ await atomic(ctx.file, { ...state, activities: [...state.activities, activity],
338
+ executionAttempts: state.executionAttempts.map(item => item === ready ? { ...item, status: 'used', executionId } : item) });
339
+ return { ...activity, authority: ready.authority };
340
+ });
341
+ }
342
+ export async function finishTaskHandoffExecution(input) {
343
+ const ctx = await context(input, true);
344
+ return withStateLock(ctx.file, async () => {
345
+ const state = await read(ctx); const item = state.activities.find(value => value.executionId === input.executionId);
346
+ if (!item || item.token !== input.token || item.pid !== process.pid) fail('in-flight execution ownership token is invalid');
347
+ await atomic(ctx.file, { ...state, activities: state.activities.filter(value => value !== item) });
348
+ });
349
+ }
350
+ function boundChallenge(ctx, challenge, purpose) {
351
+ const value = authority(challenge, CHALLENGE_FIELDS, CHALLENGE); lifetime(value, false);
352
+ for (const name of ['challengeId', 'handoffId', 'messageRequestId', 'taskId', 'flowId', 'ownerId', 'checkpointReceiptId']) id(value[name], name);
353
+ for (const name of ['taskDigest', 'chainDigest', 'checkpointDigest', 'originalScopeDigest', 'repositoryFactsDigest', 'scopeSnapshotDigest', 'runnerKeyId']) {
354
+ if (typeof value[name] !== 'string' || !SHA.test(value[name])) fail(`handoff ${name} digest is invalid`);
355
+ }
356
+ epoch(value.expectedEpoch);
357
+ if (value.purpose !== purpose || value.flowId !== ctx.flowId || value.localRepositoryDigest !== ctx.repo) fail('handoff purpose/repository binding is invalid');
358
+ if (!Array.isArray(value.paths) || !value.paths.length || value.paths.length > 512
359
+ || new Set(value.paths.map(safePath)).size !== value.paths.length || canonical([...value.paths].sort()) !== canonical(value.paths)) fail('handoff paths must be unique exact sorted file paths');
360
+ if (pinned('ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY').keyId !== value.runnerKeyId) fail('handoff runner identity does not match its trusted pin');
361
+ return value;
362
+ }
363
+ async function entries(directory) {
364
+ try { const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail('actual local state directory must be real');
365
+ const names = await fs.readdir(directory); if (names.length > 4096) fail('actual local state exceeds its bounded limit'); return names.sort(); }
366
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
367
+ }
368
+ async function idle(ctx, state) {
369
+ if (state.activities.length) fail('actual in-flight execution prevents an idle handoff');
370
+ const directory = path.join(ctx.home, '.entrorove/workflow-local', ctx.repo, ctx.flowId); const observed = [];
371
+ for (const step of await entries(directory)) {
372
+ id(step, 'actual step'); const row = await json(path.join(directory, step, 'state.json'));
373
+ if (row.flowId !== ctx.flowId || row.repositoryDigest !== ctx.repo || row.stepId !== step) fail('actual workflow state identity is invalid');
374
+ if (row.status === 'running') fail('actual running/unknown workflow cannot be released, even after lease or PID expiry');
375
+ if (!['reported', 'failed'].includes(row.status) || !row.finishedAt || !row.result || row.resultDigest !== sha256(canonical(row.result))) fail('actual workflow outcome is unknown');
376
+ observed.push({ stepId: step, status: row.status, resultDigest: row.resultDigest, leaseId: row.leaseId });
377
+ }
378
+ return sha256(canonical(observed));
379
+ }
380
+ async function revocations(ctx, state) {
381
+ const directory = path.join(ctx.home, '.entrorove/gates', ctx.repo, ctx.flowId); const passes = [];
382
+ for (const name of await entries(directory)) {
383
+ if (!name.endsWith('.json')) fail('actual pass state contains an unknown file');
384
+ const pass = await json(path.join(directory, name));
385
+ if (pass.flowId !== ctx.flowId || pass.repositoryDigest !== ctx.repo || name !== `${id(pass.passId, 'passId')}.json`) fail('actual pass binding is invalid');
386
+ passes.push(pass.passId);
387
+ }
388
+ const file = path.join(ctx.home, '.entrorove/coordination', ctx.repo, ctx.flowId, 'state.json');
389
+ return withStateLock(file, async () => {
390
+ let value; try { value = await json(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
391
+ const leases = state.executionAttempts.filter(item => item.status === 'ready').map(item => item.authority.leaseId);
392
+ if (value) {
393
+ if (value.schemaVersion !== 'entrorove.coordination/1.0' || value.flowId !== ctx.flowId || value.repositoryDigest !== ctx.repo
394
+ || !Array.isArray(value.workers) || !Array.isArray(value.leases) || !Array.isArray(value.events)) fail('actual coordination state is invalid');
395
+ const revoke = worker => { if (worker.status !== 'active') return worker; leases.push(id(worker.leaseId, 'leaseId'));
396
+ return { ...worker, status: 'revoked', revokedAt: new Date().toISOString() }; };
397
+ await atomic(file, { ...value, workers: value.workers.map(revoke), leases: value.leases.map(revoke),
398
+ events: [...value.events, { event: 'handoff-revoked', at: new Date().toISOString() }] });
399
+ }
400
+ return { passes: [...new Set([...state.revokedPassIds, ...passes])].sort(), leases: [...new Set([...state.revokedLeaseIds, ...leases])].sort() };
401
+ });
402
+ }
403
+ function signedReceipt(challenge, key, data) {
404
+ const now = Date.now(); const core = { schemaVersion: 'entrorove.task-handoff-idle-receipt/1.0',
405
+ purpose: challenge.purpose, status: 'paused', challengeId: challenge.challengeId, challengeDigest: sha256(canonical(challenge)),
406
+ handoffId: challenge.handoffId, taskId: challenge.taskId, flowId: challenge.flowId, ownerId: challenge.ownerId,
407
+ taskDigest: challenge.taskDigest, chainDigest: challenge.chainDigest, checkpointReceiptId: challenge.checkpointReceiptId,
408
+ checkpointDigest: challenge.checkpointDigest, originalScopeDigest: challenge.originalScopeDigest,
409
+ localRepositoryDigest: challenge.localRepositoryDigest, repositoryFactsDigest: challenge.repositoryFactsDigest,
410
+ scopeSnapshotDigest: challenge.scopeSnapshotDigest, paths: challenge.paths, ...data,
411
+ keyId: key.config.keyId, nonce: randomUUID(), issuedAt: new Date(now).toISOString(),
412
+ expiresAt: new Date(Math.min(now + key.config.receiptTtlMs, Date.parse(challenge.expiresAt))).toISOString() };
413
+ return { ...core, signature: sign(null, Buffer.from(canonical(core)), key.privateKey).toString('base64url') };
414
+ }
415
+ export async function releaseTaskHandoffFence(input) {
416
+ const ctx = await context(input, true); const challenge = boundChallenge(ctx, input.challenge, 'release');
417
+ return withStateLock(ctx.registry, () => withStateLock(ctx.file, async () => {
418
+ const registry = await readRegistry(ctx.registry); const registration = registry.bindings[ctx.registryKey];
419
+ const state = registration ? await read(ctx) : fresh(ctx); const digest = sha256(canonical(challenge));
420
+ if (state.releaseReceipt?.challengeId === challenge.challengeId) {
421
+ if (state.releaseReceipt.challengeDigest !== digest) fail('handoff challenge replay binding changed'); return state.releaseReceipt;
422
+ }
423
+ lifetime(challenge);
424
+ const renewPaused = state.status === 'paused' && state.epoch === challenge.expectedEpoch + 1;
425
+ if (renewPaused) {
426
+ if (Date.parse(state.releaseReceipt?.expiresAt) > Date.now()) fail('source is already paused with a current receipt; only a known expired authority may renew');
427
+ if (!state.releaseReceipt || ['handoffId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'checkpointReceiptId',
428
+ 'checkpointDigest', 'originalScopeDigest', 'localRepositoryDigest', 'repositoryFactsDigest', 'scopeSnapshotDigest'].some(key => state.releaseReceipt[key] !== challenge[key])
429
+ || canonical(state.releaseReceipt.paths) !== canonical(challenge.paths)) fail('paused release retry must preserve the exact original binding');
430
+ } else active(state, { epoch: challenge.expectedEpoch, paths: challenge.paths });
431
+ if (state.receiverRestoration && challenge.originalScopeDigest !== state.receiverRestoration.receiverOriginalScopeDigest) fail('source handoff must preserve the actually restored original scope contract');
432
+ if (registration && ['taskId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId'].some(key => registration[key] !== challenge[key])) fail('release challenge changed its original runner registration');
433
+ if (!registration && challenge.expectedEpoch !== 0) fail('lost handoff history cannot bootstrap from a later release epoch');
434
+ const key = await signer(ctx, input.signerConfigPath, challenge.runnerKeyId); const workflowStateDigest = await idle(ctx, state);
435
+ if ((await fingerprints(ctx, challenge.paths)).digest !== challenge.scopeSnapshotDigest) fail('actual scoped fingerprint changed from the authoritative snapshot');
436
+ const revoked = renewPaused ? { passes: state.revokedPassIds, leases: state.revokedLeaseIds } : await revocations(ctx, state);
437
+ const nextEpoch = renewPaused ? state.epoch : state.epoch + 1;
438
+ const receipt = signedReceipt(challenge, key, { epoch: nextEpoch,
439
+ workflowStateDigest, revokedPassIds: revoked.passes, revokedLeaseIds: revoked.leases });
440
+ if (!registration) {
441
+ const binding = Object.fromEntries(['taskId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'runnerKeyId', 'localRepositoryDigest'].map(key => [key, challenge[key]]));
442
+ await atomic(ctx.registry, { ...registry, bindings: { ...registry.bindings, [ctx.registryKey]: binding } });
443
+ }
444
+ await atomic(ctx.file, { ...state, status: 'paused', epoch: nextEpoch, revokedPassIds: revoked.passes,
445
+ revokedLeaseIds: revoked.leases, releaseReceipt: receipt, baselineReceipt: null, resumeAuthorization: null,
446
+ executionAttempts: state.executionAttempts.map(item => item.status === 'ready' ? { ...item, status: 'revoked' } : item) }); return receipt;
447
+ }));
448
+ }
449
+ export async function observeTaskHandoffBaseline(input) {
450
+ const ctx = await context(input, true); const challenge = boundChallenge(ctx, input.challenge, 'resume');
451
+ return withStateLock(ctx.file, async () => {
452
+ const state = await read(ctx); const original = state.releaseReceipt;
453
+ if (state.baselineReceipt?.challengeId === challenge.challengeId) {
454
+ if (state.baselineReceipt.challengeDigest !== sha256(canonical(challenge))) fail('baseline challenge replay binding changed'); return state.baselineReceipt;
455
+ }
456
+ if (state.status !== 'paused' || state.epoch !== challenge.expectedEpoch || !original
457
+ || ['handoffId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest', 'originalScopeDigest'].some(key => original[key] !== challenge[key])
458
+ || canonical(original.paths) !== canonical(challenge.paths)) fail('resume baseline must bind the paused original task');
459
+ lifetime(challenge); const key = await signer(ctx, input.signerConfigPath, challenge.runnerKeyId); const workflowStateDigest = await idle(ctx, state);
460
+ if ((await fingerprints(ctx, challenge.paths)).digest !== challenge.scopeSnapshotDigest) fail('fresh actual baseline fingerprint does not match authoritative scope snapshot');
461
+ const receipt = signedReceipt(challenge, key, { epoch: state.epoch, workflowStateDigest, revokedPassIds: state.revokedPassIds, revokedLeaseIds: state.revokedLeaseIds });
462
+ await atomic(ctx.file, { ...state, baselineReceipt: receipt }); return receipt;
463
+ });
464
+ }
465
+ export async function restoreTaskHandoffFence(input) {
466
+ const ctx = await context(input, true); const authorization = authority(input.authorization, RESUME_FIELDS, RESUME);
467
+ return withStateLock(ctx.file, async () => {
468
+ const state = await read(ctx); const baseline = state.baselineReceipt;
469
+ if (state.resumeAuthorization && canonical(state.resumeAuthorization) === canonical(input.authorization)) return state;
470
+ lifetime(authorization);
471
+ if (state.status !== 'paused' || !baseline || authorization.epoch !== state.epoch || authorization.baselineReceiptDigest !== sha256(canonical(baseline))
472
+ || authorization.baselineChallengeId !== baseline.challengeId || ['handoffId', 'taskId', 'flowId', 'ownerId', 'localRepositoryDigest',
473
+ 'taskDigest', 'chainDigest', 'originalScopeDigest', 'scopeSnapshotDigest'].some(key => authorization[key] !== baseline[key])) fail('confirmed resume authorization is outside the original task/baseline');
474
+ await idle(ctx, state);
475
+ if ((await fingerprints(ctx, baseline.paths)).digest !== authorization.scopeSnapshotDigest) fail('actual baseline changed before confirmed resume');
476
+ const restored = { ...state, status: 'active', resumeAuthorization: input.authorization }; await atomic(ctx.file, restored); return restored;
477
+ });
478
+ }
@@ -0,0 +1,38 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { canonical, sha256 } from './local-integrity.mjs';
5
+ import { regular } from './local-task-handoff-storage.mjs';
6
+ import { scopeRead, scopeTarget, lineCount } from './local-scope-files.mjs';
7
+
8
+ function fail(message) { throw Object.assign(new Error(message), { code: 'ENTROROVE_HANDOFF_FENCE' }); }
9
+ export async function loadTaskHandoffSigner(ctx, file, keyId) {
10
+ async function control(target) {
11
+ if (typeof target !== 'string' || !path.isAbsolute(target)) fail('runner signer controls are unavailable; an external absolute configuration is required');
12
+ const actual = await fs.realpath(target), relative = path.relative(ctx.root, actual);
13
+ if (actual !== path.resolve(target) || relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) fail('runner signer controls must be external real files');
14
+ const value = await regular(target, 16 * 1024);
15
+ if ((process.platform !== 'win32' && (value.stat.mode & 0o077)) || (typeof process.getuid === 'function' && value.stat.uid !== process.getuid())) fail('runner signer controls must be owned private files');
16
+ return value.bytes;
17
+ }
18
+ const config = JSON.parse((await control(file)).toString('utf8'));
19
+ if (Object.keys(config).sort().join('\0') !== ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'].sort().join('\0')
20
+ || config.schemaVersion !== 'validator.runner-signer/1.0' || config.keyId !== keyId
21
+ || !Number.isSafeInteger(config.receiptTtlMs) || config.receiptTtlMs < 1 || config.receiptTtlMs > 90_000) fail('runner signer configuration is invalid');
22
+ const privateKey = createPrivateKey(await control(config.privateKeyPath));
23
+ const der = createPublicKey(privateKey).export({ type: 'spki', format: 'der' });
24
+ if (privateKey.asymmetricKeyType !== 'ed25519' || sha256(der) !== keyId
25
+ || der.toString('base64') !== process.env.ENTROROVE_TASK_HANDOFF_RUNNER_PUBLIC_KEY) fail('runner signer identity is not the configured trusted receiver');
26
+ return { privateKey, config };
27
+ }
28
+ export async function taskHandoffFingerprints(ctx, paths) {
29
+ const files = []; let total = 0;
30
+ for (const relative of paths) {
31
+ const actual = await scopeTarget(ctx.root, relative, true);
32
+ if (!actual.exists) { files.push({ path: relative, sha256: sha256(''), bytes: 0, lines: 0, exists: false }); continue; }
33
+ const observed = await scopeRead(ctx.root, relative, { home: ctx.home, repositoryRoot: ctx.root, flowId: ctx.flowId }); total += observed.bytes;
34
+ if (total > 16 * 1024 * 1024) fail('scope snapshot observation exceeds its limit');
35
+ files.push({ path: relative, sha256: observed.sha256, bytes: observed.bytes, lines: lineCount(observed.content) });
36
+ }
37
+ return { files, digest: sha256(canonical(files)) };
38
+ }