@entrorove/skill 0.1.58 → 0.1.60
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY-MAP.md +2 -0
- package/CHANGELOG.md +10 -0
- package/IDE-EXECUTION.md +13 -1
- package/SKILL.md +3 -2
- package/cli.mjs +5 -2
- package/connected-cli.mjs +2 -0
- package/ide-command.mjs +2 -2
- package/ide-exit.mjs +26 -0
- package/ide-hook.mjs +19 -14
- package/ide-install.mjs +1 -1
- package/ide-session.mjs +1 -1
- package/local-mutation-authority.mjs +5 -1
- package/local-mutation-wire.mjs +4 -1
- package/local-scope-signature.mjs +6 -2
- package/manifest.json +1 -1
- package/mutation-client.mjs +3 -2
- package/package.json +9 -1
- package/release-state.json +27 -19
- package/release.mjs +9 -1
- package/releases.json +12 -0
- package/scope-amendment-client.mjs +123 -0
- package/scope-amendment-mutation.mjs +26 -0
- package/scope-amendment-observer.mjs +63 -0
- package/scope-amendment-reference.mjs +35 -0
- package/scope-amendment-remote.mjs +74 -0
- package/scope-amendment-state.mjs +94 -0
- package/scope-amendment-wire.mjs +83 -0
- package/workflow-local-contracts.mjs +2 -0
- package/workflow-local-runner.mjs +5 -3
package/CAPABILITY-MAP.md
CHANGED
|
@@ -93,6 +93,8 @@ Entrorove 只治理通过技能入口执行的动作:路径、通行证、快
|
|
|
93
93
|
|
|
94
94
|
`scope-connect <absolute repositoryRoot>` 是 CLI 编排入口,不改变这 100 项本地操作。它串联远端 `scope-runner-prepare/register/challenge/attest` 与已有真实快照、签名检查,为原账号与任务提交范围证据;不授予执行证据签名或写入权限。全部远端操作以实时 discovery 为准。
|
|
95
95
|
|
|
96
|
+
`scope-amend <original repositoryRoot>` 使用原 S 签名人完成同一 flow 的精确范围补正。先通过 `workflow-scope-review` 审核完整文件、哈希与写入/只读角色,再提供 `{flowId,proposalId,proposalDigest}`。真实审批、原合同和剩余预算仍有效;成功后计划绑定返回的 `scopeRevisionDigest`,原入口与失败历史保留。详见 [IDE-EXECUTION.md](IDE-EXECUTION.md#correcting-omitted-scope-before-plan-freeze)。
|
|
97
|
+
|
|
96
98
|
当前增量操作(不改写上述历史分母)为:`peer-status`、`discipline-check`、`discipline-review`、`flow-cancel`、`flow-abandon`、`guarded-delete`、`guarded-move`、`mutation-delegate-prepare`、`mutation-consumption-attest`。它们计入当前 109 项公开 schema。
|
|
97
99
|
|
|
98
100
|
| 分区 | 精确操作名 | 证据位置 |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.60 — 2026-10-17
|
|
4
|
+
|
|
5
|
+
- 修复范围补正误用独立交接的完整依赖校验,接收原账号签名器的真实受限扫描并保留未解析依赖、原审批与预算;新增当前 IDE 会话离线 exit,识别明确停用决定并保持退出状态,不再要求重建流程。
|
|
6
|
+
- Fix scope amendments to verify the original member signer wire receipt while preserving unresolved imports, owner approval and native budgets. Add offline exit for the current IDE session, recognize explicit stop decisions and preserve exited governance without requiring a replacement flow.
|
|
7
|
+
|
|
8
|
+
## 0.1.59 — 2026-10-17
|
|
9
|
+
|
|
10
|
+
- 新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。
|
|
11
|
+
- Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh.
|
|
12
|
+
|
|
3
13
|
## 0.1.58 — 2026-10-17
|
|
4
14
|
|
|
5
15
|
- 修复截图等多模态 PostToolUse 事件超过旧输入上限时被误拦:完整读取并严格校验最多 32 MiB 的 UTF-8 JSON 对象,超限或无效事件返回固定安全诊断;保留原授权、待回执调用及 O 证据,私有状态文件仍限 1,000,000 字节。
|
package/IDE-EXECUTION.md
CHANGED
|
@@ -14,6 +14,8 @@ Approved Codex `exec_command` reads and SDK calls run with `/bin/sh` and `login:
|
|
|
14
14
|
|
|
15
15
|
For a Codex task-to-task report explicitly authorized by the human user, the guard passes the exact `mcp__codex_app__send_message_to_thread` tool through unchanged, including a static `tools.mcp__codex_app__send_message_to_thread({...})` call inside `functions.exec`. Codex still decides whether the user authorized the message. Its text and response grant no Entrorove flow, file, shell or task-creation authority.
|
|
16
16
|
|
|
17
|
+
To stop governance only in the current IDE session after an explicit user decision, run `entrorove exit`. It needs no network, credential, agreement or hook callback and preserves original evidence and queues. Other sessions remain unchanged. `flow-cancel` and `flow-abandon` settle a flow; they do not express a decision to stop using the skill.
|
|
18
|
+
|
|
17
19
|
To stop using Entrorove on this computer, run `entrorove uninstall` directly. It works offline, removes only the recorded Entrorove host hooks and pristine global skill, and leaves other host settings, credentials and task records in place. A hand-edited file is reported as preserved rather than deleted. This command does not inspect or change another project.
|
|
18
20
|
|
|
19
21
|
Every invocation rereads that same credential file, including clients already running. After an authenticated account page supplies the current key and `configure` synchronizes it once, all tasks under the same OS user automatically use the new value. A revoked or expired key cannot obtain new permissions for itself: return to the authenticated account page and synchronize once through JSON stdin. Status recovery only queries the original request; it never rotates credentials or repeats the operation.
|
|
@@ -76,6 +78,16 @@ When intake returns `workflow-gate`, connect the current repository before invok
|
|
|
76
78
|
|
|
77
79
|
Scope inspection resolves aliases from actual `tsconfig.json` or `jsconfig.json`, including relative and npm `extends` and ordered inheritance arrays. Npm configuration lookup uses the pinned TypeScript JSON-config resolver over controlled metadata reads, including scoped packages, subpaths, package `tsconfig`, `exports` and `typesVersions`. Read-only candidate probes treat a child beneath an ordinary file as absent so normal TypeScript fallbacks continue; actual sources, control files and mutation targets retain strict validation. Rejected paths report the requested path, blocking ancestor, resolved target and reason, including broken or cyclic dependency links. Configurations and package metadata enter the signed hashes; dependencies are read only, never executed. Repository-local pnpm links use their actual configuration location. Read-only external dependency packages support parents and mappings inside their reference boundary; references requiring an unrepresentable external canonical ancestor fail explicitly rather than selecting a different same-name repository package. For plugin source compiled by a host project whose config is outside the source's ancestors, include `importContexts: [{"sourcePrefix":"<plugin source directory>","configPath":"<actual host tsconfig.json>"}]` in `scope-connect` input. Inspect the real build configuration first; never invent an alias or mark an unresolved import as external. These context paths remain inside the repository, their hashes enter the signed facts, and the local task binding freezes them.
|
|
78
80
|
|
|
81
|
+
### Correcting omitted scope before plan freeze
|
|
82
|
+
|
|
83
|
+
If the original selection omitted actual components or inspection files, use the discovered `workflow-scope-review` on the **same flow**, with its original gate digest, current effective revision (`0` before the first amendment), and the complete sorted exact file map. Each entry declares its real `path`, `sha256`, `access` (`write` or `read-only`), original `requirementRefs` and reason. Include all original entries even when reducing their access to read-only. Existing dependencies may become writable only through this exact new review; a directory prefix or manifest inclusion does not grant that permission.
|
|
84
|
+
|
|
85
|
+
Present the returned scope-revision question and submit the original owner's actual answer through its bound `interaction-answer`. Approval is specific to that complete proposal; it does not approve a plan or perform a write. Then run `npx @entrorove/skill scope-amend <original repositoryRoot>` with `{flowId,proposalId,proposalDigest}` on stdin. It loads the **existing** S key and original repository/entry/import-context binding, obtains a W challenge, scans the real complete file set into a separate candidate snapshot and submits signed evidence. It never creates a replacement signer, answers approval itself, or overwrites the original scope history.
|
|
86
|
+
|
|
87
|
+
Only a successful native gate and the original approved contracts and remaining budgets can activate the immutable scope revision. Keep the original Blueprint, plan identities, task order and failure receipts. Submit the complete corrected plan with the returned `scopeRevisionDigest` under its canonical new request identity. Exact read-only files remain read-only, even if included in the manifest or a broad write contract. An unknown request outcome is recovered with the original GET; do not replay an uncertain POST or restart the flow.
|
|
88
|
+
|
|
89
|
+
After an amended task's actual guarded writes, follow its returned `scope-amend` refresh action, which adds the original stored `gatePassPath` to the same `{flowId,proposalId,proposalDigest}`. The connector preserves the real M consumption receipt and observes new S facts without another write. It appends facts under the same approved role revision, then the original `mutation-settle` and local observation can continue. Changed read-only files, a different pass or extra writes remain blocked. A proof refresh cannot change permissions or reset a consumed budget. Live `workflow-scope-status` observes the current head; an uncertain mutating request is still recovered only through its original GET.
|
|
90
|
+
|
|
79
91
|
Keep snapshot before-images distinct from a frozen execution plan's expected final file hashes. Controlled writes consume the original approved authority; the runner then verifies the actual planned final bytes. Never edit a signed plan or silently substitute a newer snapshot after an unknown or failed gate. Existing changes outside that authority require explicit revalidation of the same original flow.
|
|
80
92
|
|
|
81
93
|
Computed runtime imports remain explicitly unresolved with their actual source paths and hashes. A member's scope-only signer may submit these observations to enter restricted planning; the cloud returns `planningOnly: true` and `requires-complete-import-graph-before-mutation`. Do not treat this as full dependency verification, execution permission, or business completion. Obtain the limited original-task editing delegation below when eligible, or resolve and verify the dependency contract. Never set `complete` manually or replace the original signer or paths.
|
|
@@ -90,7 +102,7 @@ The connector creates a separate local editing key, requests the cloud's origina
|
|
|
90
102
|
|
|
91
103
|
Use the returned `gatePassPath` with the discovered `local guarded-write` contract and preserve its exact flow, step and repository. Only existing declared modify targets are eligible. Runtime loader sources and observed project configuration stay protected by their original hashes and physical file identity. The local preflight rejects changed imports, removed definitions, public contract changes, new unknown dependencies and unsupported source formats. Unknown dependency coverage remains unknown after an eligible edit.
|
|
92
104
|
|
|
93
|
-
After the pass's actual writes finish,
|
|
105
|
+
After the pass's actual writes finish, follow its scope refresh action: an amended scope uses `scope-amend` with the original stored `gatePassPath`; an unchanged original scope uses its complete original `scope-connect` input, including `importContexts`. Run `npx @entrorove/skill mutation-settle <original repositoryRoot>` with `{taskId,flowId,stepId,gatePassPath}`. The local M signer observes the real completed consumption, and the service requires a fresh S scan captured after those writes. This settlement preserves both before and after hashes, unchanged modes, pass nonces and the original lease. It never creates an architecture verification event. Further edits use the returned `localAction`, whose `previousPassId` identifies that exact settled pass; do not resend an already consumed write.
|
|
94
106
|
|
|
95
107
|
If settlement reports `ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED`, refresh only the original scope and retry the same consumption request and receipt. A lost network response is different: rerunning the identical connector restores its original GET result without a replacement POST or signature.
|
|
96
108
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Execute an engineering task through Entrorove, preserving its origi
|
|
|
5
5
|
|
|
6
6
|
# entrorove · 熵巡
|
|
7
7
|
|
|
8
|
-
Version: 0.1.
|
|
8
|
+
Version: 0.1.60.
|
|
9
9
|
|
|
10
10
|
Entrorove applies to the whole task once selected. Reading this document or calling an entry once does not execute the task. On every later turn, including after compaction, continue the original task and its required next operation before dependent work. Keep the original goal, scope, account, flow, cloud task and failure records. Never describe planning, a reported step or model output as verified delivery.
|
|
11
11
|
|
|
@@ -26,7 +26,7 @@ Entrorove applies to the whole task once selected. Reading this document or call
|
|
|
26
26
|
8. Read [DELIVERY-BOUNDARIES.md](DELIVERY-BOUNDARIES.md) before claiming a delivered stage or showing consumption. A delivered stage requires a clean boundary and uses `delivered-stage-N`. Blocked or unknown work can wait with recovery data intact; it is not a delivered stage. Display consumption only with a measured baseline.
|
|
27
27
|
9. For independent verified completion, submit actual independent execution evidence and run server verification and verdict. For a signed member-local plan, finish all original local steps and requirement checkpoints, then call the returned `workflow-verdict`; only its signed final `localAcceptance` means local acceptance is complete. Describe it as local execution and tests accepted, with independent validation not performed. Never invoke formal V, merge, Swarm or handoff to relabel it. Display the confirmed evaluation acknowledgement and preserve `receiptId`, `requestId`, execution generation and evaluation digest. “已回传” requires `acknowledgement.stored: true`. Missing or failed feedback remains visible. Active, waiting and unknown tasks retain recovery data.
|
|
28
28
|
|
|
29
|
-
The Codex guard preserves this binding over subsequent prompts and compaction and denies unsupported native mutations before execution. Hooks are host guardrails, not an operating-system security boundary; unsupported tool paths or a disabled/untrusted hook cannot be described as enforced. An explicit user decision to stop using the skill ends its governance and never becomes verified Entrorove delivery. Do not infer such a decision from quoted screenshots or a service failure.
|
|
29
|
+
The Codex guard preserves this binding over subsequent prompts and compaction and denies unsupported native mutations before execution. Hooks are host guardrails, not an operating-system security boundary; unsupported tool paths or a disabled/untrusted hook cannot be described as enforced. An explicit user decision to stop using the skill ends its governance and never becomes verified Entrorove delivery. Do not infer such a decision from quoted screenshots or a service failure. For that explicit decision, run `entrorove exit` in the affected IDE session. It works offline without a credential, agreement or hook callback, stops governance for that session and preserves its original flow, failed requests and queued work. It does not cancel the business goal, resume another flow or grant an Entrorove mutation pass. Do not substitute `flow-abandon` for user exit: abandonment releases a flow binding but leaves the skill selected.
|
|
30
30
|
|
|
31
31
|
After the same real blocked result occurs twice, diagnose it and use its supported recovery path. Do not send a third identical execution call. Read-only status and checkpoint acknowledgements do not count as execution progress. A blocked task may wait with its original flow, queue and recovery data intact; Stop must not force repeated automatic continuation of the same blocked turn.
|
|
32
32
|
|
|
@@ -39,6 +39,7 @@ When a new user requirement arrives while the original task is unfinished, store
|
|
|
39
39
|
- Invoke service operations with JSON stdin to `npx @entrorove/skill invoke` or a host-approved rewritten equivalent.
|
|
40
40
|
- Explicit domains use `native <domain> <operation>`. Domains: aimlock, blueprint, calctool, swarm, validator, mergeguard, archguard, confirm-protocol. Aimlock `brain-plan`, `brain-check`, `brain-validate`, `brain-status` use the original stored plan.
|
|
41
41
|
- At `workflow-gate`, connect the actual repository and exact source paths with `scope-connect`, then use its original `nextInput`. Scope inspection does not grant execution or writes. Dynamic references remain unknown; never set graph completeness manually.
|
|
42
|
+
- If actual files were omitted before plan freeze, follow the discovered same-flow `workflow-scope-review`, original owner approval and `scope-amend` recovery in [IDE-EXECUTION.md](IDE-EXECUTION.md#correcting-omitted-scope-before-plan-freeze). Preserve each file's explicit write or read-only role and bind the resulting `scopeRevisionDigest` in the corrected plan; never widen ordinary `scope-connect` input to bypass its original binding.
|
|
42
43
|
- `local capabilities` describes scope, snapshot, budget, ownership, mutation, review and evidence contracts. Do not call a guessed operation or construct a local signature in place of cloud authority.
|
|
43
44
|
- Cloud events and homepage progress share the same account-bound stream. Show public entering, allocation, model activity, output and termination as they occur. Show the server-selected promotion and execution notice separately. Deduplicate task/event/receipt IDs; never invent progress or model reasoning.
|
|
44
45
|
- `status`, `cloud-status` and `feedback` recover the original identity with GET only. Never repeat a paid POST to recover an uncertain result.
|
package/cli.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { activeIdeAdapters, ideAdapters, installIdeHooks, uninstallIdeHooks } fr
|
|
|
21
21
|
import { cleanManagedDuplicates } from './install-cleanup.mjs';
|
|
22
22
|
import { assertIdeExecution, ideGuardBinding, settleBreakGlassSession } from './ide-session.mjs';
|
|
23
23
|
import { writeBreakGlass } from './break-glass.mjs';
|
|
24
|
+
import { exitIdeSession } from './ide-exit.mjs';
|
|
24
25
|
const markerName = '.entrorove-managed.json';
|
|
25
26
|
const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md'];
|
|
26
27
|
const markerSchema = 'entrorove.managed-install/1.0';
|
|
@@ -427,7 +428,9 @@ async function runAccountCommand(command, args) {
|
|
|
427
428
|
|
|
428
429
|
async function main() {
|
|
429
430
|
const [command, ...args] = process.argv.slice(2);
|
|
430
|
-
if (command === '
|
|
431
|
+
if (command === 'exit' && args.length === 0) {
|
|
432
|
+
process.stdout.write(`${JSON.stringify(await exitIdeSession())}\n`);
|
|
433
|
+
} else if (command === 'unlock' && args.length === 0) {
|
|
431
434
|
process.stdout.write(`${JSON.stringify(await writeBreakGlass(os.homedir(), Date.now(), ideGuardBinding().sessionId))}\n`);
|
|
432
435
|
} else if (command === 'uninstall' && args.length === 0) {
|
|
433
436
|
const result = await uninstall();
|
|
@@ -477,7 +480,7 @@ async function main() {
|
|
|
477
480
|
} else if (command === 'local') {
|
|
478
481
|
await runLocal(args);
|
|
479
482
|
} else {
|
|
480
|
-
throw new Error('Usage: entrorove unlock | uninstall | install [--host codex] | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | mutation-connect <repositoryRoot> | mutation-settle <repositoryRoot> | workflow-observe-step <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | usage [flowId] | billing | recurring list|claim|finish|runs | review | local (JSON on stdin)');
|
|
483
|
+
throw new Error('Usage: entrorove exit | unlock | uninstall | install [--host codex] | configure | agreement | invoke | queue | native <domain> <operation> | notifications configure|status|test | scope-connect <repositoryRoot> | scope-amend <repositoryRoot> | mutation-connect <repositoryRoot> | mutation-settle <repositoryRoot> | workflow-observe-step <repositoryRoot> | host-message [adapterModulePath] | cloud-task | cloud-watch | cloud-status | feedback <requestId> [generation] | status <requestId> | flows | usage [flowId] | billing | recurring list|claim|finish|runs | review | local (JSON on stdin)');
|
|
481
484
|
}
|
|
482
485
|
}
|
|
483
486
|
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { const { captureIdeResults } = await import('./ide-results.mjs');
|
package/connected-cli.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { runScopeConnect } from './scope-runner-client.mjs';
|
|
2
2
|
import { runMutationConnect, runMutationSettle } from './mutation-client.mjs';
|
|
3
3
|
import { runWorkflowObserveStep } from './workflow-observation-client.mjs';
|
|
4
|
+
import { runScopeAmend } from './scope-amendment-client.mjs';
|
|
4
5
|
|
|
5
6
|
const commands = Object.freeze({
|
|
6
7
|
'scope-connect': runScopeConnect,
|
|
8
|
+
'scope-amend': runScopeAmend,
|
|
7
9
|
'mutation-connect': runMutationConnect,
|
|
8
10
|
'mutation-settle': runMutationSettle,
|
|
9
11
|
'workflow-observe-step': runWorkflowObserveStep,
|
package/ide-command.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export function sdkCommand(parsed, runtime) {
|
|
|
76
76
|
args = words.slice(1);
|
|
77
77
|
} else return null;
|
|
78
78
|
const simple = new Set(['invoke', 'cloud-task', 'cloud-status', 'cloud-watch', 'agreement', 'configure', 'queue', 'unlock']);
|
|
79
|
-
if (['unlock', 'uninstall'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
|
|
79
|
+
if (['exit', 'unlock', 'uninstall'].includes(args[0]) && args.length === 1 && !parsed.input) return { args };
|
|
80
80
|
if (simple.has(args[0]) && args.length === 1 && parsed.input) return { args, input: parsed.input };
|
|
81
81
|
if (['status', 'feedback'].includes(args[0]) && /^[a-f0-9]{64}$/u.test(args[1] ?? '') && [2,3].includes(args.length)) return { args };
|
|
82
82
|
if (args[0] === 'flows' && args.length === 1 && (!parsed.input || typeof parsed.input === 'object'
|
|
@@ -88,7 +88,7 @@ export function sdkCommand(parsed, runtime) {
|
|
|
88
88
|
if (args[0] === 'usage' && args.length <= 2 && !parsed.input
|
|
89
89
|
&& (args[1] === undefined || /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(args[1]))) return { args };
|
|
90
90
|
if (args[0] === 'local' && /^[a-z][a-z0-9-]+$/u.test(args[1] ?? '') && [2,3].includes(args.length) && parsed.input) return { args, input: parsed.input };
|
|
91
|
-
if (['scope-connect', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(args[0]) && args.length === 2 && parsed.input) return { args, input: parsed.input };
|
|
91
|
+
if (['scope-connect', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(args[0]) && args.length === 2 && parsed.input) return { args, input: parsed.input };
|
|
92
92
|
if (args[0] === 'native' && args.length === 3 && parsed.input) return { args, input: parsed.input };
|
|
93
93
|
return null;
|
|
94
94
|
}
|
package/ide-exit.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ideDirectory, ideDigest, ideError, ideGuardBinding, ideSession, readIdeJson, verifiedIdeGuards } from './ide-session.mjs';
|
|
4
|
+
|
|
5
|
+
const exactExit = /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu;
|
|
6
|
+
const naturalExit = /(?:^|[,,。;;!!\n])\s*(?:从现在开始|接下来|今后)?\s*(?:停止使用|不再使用|不使用)\s*(?:entrorove(?:\s*技能)?|(?:这个|该)?技能)\s*(?:(?:来)?(?:执行|处理|完成|继续)[^,,。;;!!\n]{0,40}(?:任务|工作))?[。!!]?\s*$/iu;
|
|
7
|
+
export function explicitIdeExit(prompt) {
|
|
8
|
+
if (exactExit.test(prompt)) return true;
|
|
9
|
+
return !/[`"'“”‘’「」『』<>]|如果|假如|假设|示例|截图|引用/u.test(prompt) && naturalExit.test(prompt);
|
|
10
|
+
}
|
|
11
|
+
export function markIdeExited(state, source) {
|
|
12
|
+
if (state.phase !== 'user-exited') state.exitPhase = state.phase;
|
|
13
|
+
state.active = false; state.phase = 'user-exited';
|
|
14
|
+
state.exitDecision = { source, observedAt: new Date().toISOString() };
|
|
15
|
+
}
|
|
16
|
+
export async function exitIdeSession({ home = os.homedir(), environment = process.env } = {}) {
|
|
17
|
+
const { host, sessionId } = ideGuardBinding(environment);
|
|
18
|
+
if (!Object.hasOwn(verifiedIdeGuards, host) || !sessionId) throw ideError('ENTROROVE_IDE_IDENTITY', 'Exit requires the current verified IDE session identity');
|
|
19
|
+
const directory = await ideDirectory(home), state = await readIdeJson(path.join(directory, `session-${ideDigest(sessionId)}.json`));
|
|
20
|
+
if (!state || state.sessionId !== sessionId) throw ideError('ENTROROVE_IDE_IDENTITY', 'Exit requires the existing current IDE session');
|
|
21
|
+
return ideSession({ session_id: sessionId, cwd: state.repositoryRoot }, current => {
|
|
22
|
+
markIdeExited(current, 'cli');
|
|
23
|
+
return { schemaVersion: 'entrorove.ide-exit/1.0', status: 'user-exited', sessionId,
|
|
24
|
+
offline: true, network: false, hook: 'not-required', originalFlowPreserved: true, claimsCompletion: false };
|
|
25
|
+
}, home);
|
|
26
|
+
}
|
package/ide-hook.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
|
|
|
13
13
|
import { verifyIdeLocalAcceptance } from './ide-local-acceptance.mjs';
|
|
14
14
|
import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
|
|
15
15
|
advanceIdeQueue, checkQueuedStart, unfinishedIdeQueue } from './ide-queue.mjs';
|
|
16
|
+
import { explicitIdeExit, markIdeExited } from './ide-exit.mjs';
|
|
16
17
|
|
|
17
18
|
const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
|
|
18
19
|
const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
|
|
@@ -24,7 +25,6 @@ const inputErrors = {
|
|
|
24
25
|
const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
|
|
25
26
|
const readTools = new Set(['wait', 'view_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource', 'web_search', 'update_plan', 'request_user_input', 'request_user_input_async', 'get_goal']);
|
|
26
27
|
const unlockCommand = 'entrorove unlock';
|
|
27
|
-
const exitPrompt = /^(?:(?:请)?(?:停止使用|退出)\s*\$?entrorove|(?:stop using|exit)\s+\$?entrorove)[.!。!]?$/iu;
|
|
28
28
|
const selectPrompt = /^(?:(?:请)?(?:使用|启用)\s+\$?entrorove|\$entrorove)(?:\s|[,。,::!!]|$)/iu;
|
|
29
29
|
function userPrompt(event) {
|
|
30
30
|
let prompt = (event.prompt ?? '').trim();
|
|
@@ -143,19 +143,24 @@ function originalLocalCall(pending, call, input) {
|
|
|
143
143
|
['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].map(field => [field, input?.[field]]));
|
|
144
144
|
if (operationOf(call) === 'continue') pending.continuationOperation = input?.operation;
|
|
145
145
|
}
|
|
146
|
+
function offlineExitCall(event, runtime, state, call) {
|
|
147
|
+
return output('PreToolUse', { permissionDecision: 'allow', updatedInput: prepareIdeShellInput(event.tool_name,
|
|
148
|
+
event.tool_input, renderSdkCommand(call, runtime, state.sessionId), true) });
|
|
149
|
+
}
|
|
150
|
+
function allowInstaller(event, state, command) {
|
|
151
|
+
const safeInput = prepareIdeShellInput(event.tool_name, event.tool_input, command, true);
|
|
152
|
+
if (!safeInput) return denyUnsafeShellInput(state, event);
|
|
153
|
+
if (state.active) { state.readCalls ??= {};
|
|
154
|
+
state.readCalls[event.tool_use_id] = { toolUseId: event.tool_use_id, command: 'install' }; }
|
|
155
|
+
return output('PreToolUse', { permissionDecision: 'allow', updatedInput: safeInput });
|
|
156
|
+
}
|
|
146
157
|
async function beforeTool(event, state, home) {
|
|
147
158
|
if (state.phase === 'user-exited') return {};
|
|
148
159
|
const unlocked = await readBreakGlass(home, state.sessionId);
|
|
149
160
|
const command = event.tool_input?.command ?? event.tool_input?.cmd, parsed = literalCommand(command);
|
|
150
161
|
const isShell = ['Bash', 'exec_command', 'shell_command'].includes(event.tool_name);
|
|
151
162
|
const installer = isShell && installerCommand(parsed);
|
|
152
|
-
if (installer)
|
|
153
|
-
const safeInput = prepareIdeShellInput(event.tool_name, event.tool_input, installer, true);
|
|
154
|
-
if (!safeInput) return denyUnsafeShellInput(state, event);
|
|
155
|
-
if (state.active) { state.readCalls ??= {};
|
|
156
|
-
state.readCalls[event.tool_use_id] = { toolUseId: event.tool_use_id, command: 'install' }; }
|
|
157
|
-
return output('PreToolUse', { permissionDecision: 'allow', updatedInput: safeInput });
|
|
158
|
-
}
|
|
163
|
+
if (installer) return allowInstaller(event, state, installer);
|
|
159
164
|
const namedSdk = parsed?.words.some(word => word === 'entrorove' || /^@entrorove\/skill(?:@[^ ]+)?$/u.test(word)
|
|
160
165
|
|| /(?:@entrorove\/skill|entrorove-skill)\/cli\.mjs$/u.test(word));
|
|
161
166
|
if (!state.active && !namedSdk) return {};
|
|
@@ -163,6 +168,7 @@ async function beforeTool(event, state, home) {
|
|
|
163
168
|
const call = runtime && sdkCommand(parsed, runtime);
|
|
164
169
|
if (call) {
|
|
165
170
|
if (!prepareIdeShellInput(event.tool_name, event.tool_input, command, true)) return denyUnsafeShellInput(state, event);
|
|
171
|
+
if (call.args[0] === 'exit' && call.args.length === 1) return offlineExitCall(event, runtime, state, call);
|
|
166
172
|
if (call.args[0] === 'uninstall') {
|
|
167
173
|
state.active = false; state.phase = 'user-exited';
|
|
168
174
|
return output('PreToolUse', { permissionDecision: 'allow', updatedInput: prepareIdeShellInput(event.tool_name,
|
|
@@ -183,11 +189,11 @@ async function beforeTool(event, state, home) {
|
|
|
183
189
|
const observer = discovery || ['status','feedback','cloud-status','configure','agreement','install','queue','flows','usage','billing','recurring'].includes(call.args[0]);
|
|
184
190
|
if (unclassifiedIdeRequirements(state) && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:新需求已保存,先用 queue status 读取并按轻重缓急 route 分类;禁止丢弃新需求或覆盖原任务。');
|
|
185
191
|
if (newStart && !checkQueuedStart(state,call)) return denyBlocked(state, event, 'Entrorove:新任务目标必须与实际进入队列的用户原文一致。');
|
|
186
|
-
if (['local', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
|
|
192
|
+
if (['local', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && !discovery && !state.flowId) return denyBlocked(state, event, 'Entrorove:本地执行前必须先受理原任务并绑定账号、flowId 与仓库;不能直接使用其它流程的通行证。');
|
|
187
193
|
if (state.pendingCall && !observer && !(unlocked && isManagementCall(call))) return denyBlocked(state, event, 'Entrorove:原调用尚未收到结束回执;先只读恢复其状态,禁止并发重发。');
|
|
188
194
|
const input = call.args[0] === 'invoke' ? call.input?.input : call.input;
|
|
189
195
|
if (input?.flowId && state.flowId && input.flowId !== state.flowId || input?.taskId && state.taskId && ['cloud-watch','cloud-status'].includes(call.args[0]) && input.taskId !== state.taskId) return denyBlocked(state, event, 'Entrorove:调用与本 IDE 任务的原编号不一致。');
|
|
190
|
-
if (['scope-connect', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && call.args[1] !== state.repositoryRoot
|
|
196
|
+
if (['scope-connect', 'scope-amend', 'mutation-connect', 'mutation-settle', 'workflow-observe-step'].includes(call.args[0]) && call.args[1] !== state.repositoryRoot
|
|
191
197
|
|| call.args[0] === 'local' && (input?.repositoryRoot && input.repositoryRoot !== state.repositoryRoot || call.args[2] && call.args[2] !== state.repositoryRoot)) return denyBlocked(state, event, 'Entrorove:调用不能替换本任务的实际仓库。');
|
|
192
198
|
let requestId;
|
|
193
199
|
if (newStart) state.taskGoal = call.args[0] === 'cloud-task' ? call.input?.goal : call.input?.input?.task?.goal;
|
|
@@ -196,7 +202,7 @@ async function beforeTool(event, state, home) {
|
|
|
196
202
|
requestId = buildInvocation(call.input.operation, call.input.input).requestId;
|
|
197
203
|
}
|
|
198
204
|
const pending = { toolUseId: event.tool_use_id, inputDigest: ideDigest(call), command: call.args[0],
|
|
199
|
-
operation: operationOf(call), observer, managementOnly: observer || isManagementCall(call)
|
|
205
|
+
operation: operationOf(call), observer, managementOnly: observer || call.args[0] === 'scope-amend' || isManagementCall(call)
|
|
200
206
|
&& !['start', 'continue', 'cloud-task'].includes(operationOf(call)) || operationOf(call) === 'task-checkpoint',
|
|
201
207
|
...(requestId ? { requestId } : {}) };
|
|
202
208
|
originalLocalCall(pending, call, input);
|
|
@@ -253,9 +259,8 @@ export async function runIdeHook(event, { home } = {}) {
|
|
|
253
259
|
delete state.stopNoticeTurn;
|
|
254
260
|
}
|
|
255
261
|
if (name === 'PreToolUse') state.enforcement = 'enforced';
|
|
256
|
-
if (name === 'UserPromptSubmit' &&
|
|
257
|
-
|
|
258
|
-
state.active = false; state.phase = 'user-exited';
|
|
262
|
+
if (name === 'UserPromptSubmit' && explicitIdeExit(userPrompt(event))) {
|
|
263
|
+
markIdeExited(state, 'user-prompt');
|
|
259
264
|
return output(name, { additionalContext: '用户明确退出 Entrorove 管理;原任务仍保留,未验收、未评分为成功。' });
|
|
260
265
|
}
|
|
261
266
|
if (selected) {
|
package/ide-install.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { ideDirectory, ideDigest, readIdeJson, writeIdeJson, ideError } from './
|
|
|
5
5
|
import { withStateLock } from './local-state-lock.mjs';
|
|
6
6
|
|
|
7
7
|
const source = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
-
const files = ['ide-hook.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-local-acceptance.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs', 'local-stage-boundary.mjs'];
|
|
8
|
+
const files = ['ide-hook.mjs', 'ide-session.mjs', 'ide-exit.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-local-acceptance.mjs', 'ide-queue.mjs', 'ide-command.mjs', 'ide-code-mode.mjs', 'remote-transport.mjs', 'local-credential.mjs', 'local-state-lock.mjs', 'local-stage-boundary.mjs'];
|
|
9
9
|
const quote = text => `'${text.replace(/'/gu, "'\\''")}'`;
|
|
10
10
|
|
|
11
11
|
// One shared host-agnostic hook binary; every host is described by its own
|
package/ide-session.mjs
CHANGED
|
@@ -204,7 +204,7 @@ export async function settleBreakGlassSession(args, input, result, { home = os.h
|
|
|
204
204
|
for (const key of ['flowId', 'taskId', 'taskNumber', 'requestId', 'remoteRequestId', 'nextStep', 'pendingCall', 'readCalls']) delete state[key];
|
|
205
205
|
state.feedbackAcknowledged = false;
|
|
206
206
|
if (state.phase !== 'user-exited') state.phase = 'entry';
|
|
207
|
-
} else state.phase = 'ended';
|
|
207
|
+
} else if (state.phase !== 'user-exited') state.phase = 'ended';
|
|
208
208
|
return true;
|
|
209
209
|
}, home);
|
|
210
210
|
}
|
|
@@ -16,6 +16,7 @@ import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
|
|
|
16
16
|
import { assertDisciplineRepository } from './local-discipline-repository.mjs';
|
|
17
17
|
import { withPreservedMutationFence } from './local-preserved-mutation-state.mjs';
|
|
18
18
|
import { assertPreservedMutationProtection } from './local-preserved-mutation-protection.mjs';
|
|
19
|
+
import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
|
|
19
20
|
export { beginMutationBatch14, finishMutationBatch14, failMutationBatch14 } from './local-mutation-batch.mjs';
|
|
20
21
|
|
|
21
22
|
const SCHEMA = 'entrorove.local-execution/1.0';
|
|
@@ -117,7 +118,10 @@ async function performWrite(input, state, value) {
|
|
|
117
118
|
await privateMutationDirectory14(path.join(stateRoot, 'ledgers', pass.repositoryDigest), { privateFrom: stateRoot });
|
|
118
119
|
await appendLedgerRecord(input, 'guarded-write', { flowId: pass.flowId, stepId: pass.stepId, targetPath: target,
|
|
119
120
|
sha256: written.sha256, passId: pass.passId });
|
|
120
|
-
|
|
121
|
+
const nextAction = pass.paths.every(file => completed.writes[file]?.status === 'written')
|
|
122
|
+
? await scopeAmendmentRefreshAction({ ...input, repositoryRoot: binding.root }, pass) : undefined;
|
|
123
|
+
return { schemaVersion: SCHEMA, status: 'verified', written: true, targetPath: target, sha256: written.sha256,
|
|
124
|
+
...(nextAction ? { nextAction } : {}) };
|
|
121
125
|
});
|
|
122
126
|
}
|
|
123
127
|
export async function guardedWrite14(input) {
|
package/local-mutation-wire.mjs
CHANGED
|
@@ -40,6 +40,9 @@ function pinnedAuthority() {
|
|
|
40
40
|
} catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
|
|
41
41
|
}
|
|
42
42
|
function identity(payload) {
|
|
43
|
+
if (Object.hasOwn(payload, 'scopeRevisionDigest') && !SHA.test(payload.scopeRevisionDigest)) {
|
|
44
|
+
mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority scope revision is invalid');
|
|
45
|
+
}
|
|
43
46
|
const local = payload.verificationMode === 'member-local';
|
|
44
47
|
if (local ? payload.runnerKeyId !== null || !payload.writeDelegation
|
|
45
48
|
|| !SHA.test(payload.memberScopeKeyId ?? '') || !SHA.test(payload.memberScopeBindingDigest ?? '')
|
|
@@ -103,7 +106,7 @@ export function mutationChallenge14(value, options) {
|
|
|
103
106
|
function authorityFields(value, fields) {
|
|
104
107
|
const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
|
|
105
108
|
!['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce'].includes(field)), 'writeDelegation'] : fields;
|
|
106
|
-
return [...original, ...['previousPassId', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
109
|
+
return [...original, ...['previousPassId', 'scopeRevisionDigest', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
|
|
107
110
|
}
|
|
108
111
|
export function mutationPass14(value, options) {
|
|
109
112
|
const fields = [...authorityFields(value, PASS_FIELDS), ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
|
|
@@ -33,7 +33,10 @@ function binding(input, result) {
|
|
|
33
33
|
for (const field of ['taskDigest', 'chainDigest']) if (!digest.test(String(input[field] ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires original task and chain digests');
|
|
34
34
|
if (!digest.test(String(result.snapshotDigest ?? ''))) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature requires a verified preserved snapshot digest');
|
|
35
35
|
}
|
|
36
|
-
export async function attestScopeFacts(input, result) {
|
|
36
|
+
export async function attestScopeFacts(input, result, { maximumExpiresAt } = {}) {
|
|
37
|
+
if (maximumExpiresAt !== undefined && (!Number.isSafeInteger(maximumExpiresAt) || maximumExpiresAt < 1)) {
|
|
38
|
+
scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'scope signature deadline must be a finite exact bound');
|
|
39
|
+
}
|
|
37
40
|
rejectScopePrivileges(input);
|
|
38
41
|
if (input.signerConfigPath === undefined || input.signerConfigPath === null) return result;
|
|
39
42
|
binding(input, result);
|
|
@@ -58,7 +61,8 @@ export async function attestScopeFacts(input, result) {
|
|
|
58
61
|
scopeFail('ENTROROVE_SCOPE_SIGNER_INVALID', 'scope signer configuration or signing key could not be read');
|
|
59
62
|
}
|
|
60
63
|
const now = Date.now();
|
|
61
|
-
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity
|
|
64
|
+
const expiresAt = Math.min(now + config.receiptTtlMs, candidate ? Date.parse(candidate.expiresAt) : Infinity,
|
|
65
|
+
maximumExpiresAt ?? Infinity);
|
|
62
66
|
if (expiresAt <= now) scopeFail('ENTROROVE_SCOPE_ATTESTATION_BINDING', 'candidate challenge expired before its actual scope signature');
|
|
63
67
|
const core = { schemaVersion: 'entrorove.scope-facts/1.0', flowId: input.flowId, stepId: input.stepId,
|
|
64
68
|
taskDigest: input.taskDigest, ownerId: input.ownerId, chainDigest: input.chainDigest, repositoryId: input.repositoryId,
|
package/manifest.json
CHANGED
package/mutation-client.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { ideClientHeaders } from './ide-presentation.mjs';
|
|
|
16
16
|
import { appendInvocation, readInvocations } from './local-invocations.mjs';
|
|
17
17
|
import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
18
18
|
import { attestMutationConsumption14, preservedMutationConsumptionState } from './local-mutation-consumption.mjs';
|
|
19
|
+
import { scopeAmendmentRefreshAction } from './scope-amendment-reference.mjs';
|
|
19
20
|
|
|
20
21
|
const schema = 'entrorove.mutation-connection-state/1.0';
|
|
21
22
|
const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', 'runnerId', 'paths'];
|
|
@@ -111,7 +112,7 @@ function passBinding(pass, challenge, attestation, options) {
|
|
|
111
112
|
mutationPass14(pass, options);
|
|
112
113
|
if (pass.challengeId !== challenge.challengeId || pass.challengeDigest !== sha256(canonical(challenge))
|
|
113
114
|
|| pass.attestationDigest !== sha256(canonical(attestation)) || pass.snapshotDigest !== attestation.manifest.snapshotDigest
|
|
114
|
-
|| [...fields, 'previousPassId', 'verificationMode', 'runnerKeyId', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeBindingDigest']
|
|
115
|
+
|| [...fields, 'previousPassId', 'scopeRevisionDigest', 'verificationMode', 'runnerKeyId', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeBindingDigest']
|
|
115
116
|
.some(field => canonical(pass[field]) !== canonical(challenge[field]))
|
|
116
117
|
|| canonical(pass.writeDelegation) !== canonical(challenge.writeDelegation)) fail('Mutation pass differs from its original signed challenge or actual M snapshot');
|
|
117
118
|
}
|
|
@@ -169,7 +170,7 @@ async function connect(identity, local, mutator, location, dependencies) {
|
|
|
169
170
|
if (consumption.complete) return { schemaVersion: 'entrorove.mutation-connection/1.0', status: 'blocked', flowId: identity.flowId,
|
|
170
171
|
code: 'ENTROROVE_MUTATION_SETTLEMENT_REQUIRED', gatePassPath: state.gatePassPath,
|
|
171
172
|
message: 'Original physical writes are consumed; settle their actual M receipt with fresh S evidence before requesting the next restricted pass',
|
|
172
|
-
localAction: { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
|
|
173
|
+
localAction: await scopeAmendmentRefreshAction({ ...input, gatePassPath: state.gatePassPath }, stored) ?? { command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>',
|
|
173
174
|
input: { taskId: stored.taskId, flowId: stored.flowId, stepId: stored.stepId, gatePassPath: state.gatePassPath } } };
|
|
174
175
|
passBinding(state.issued.pass, challenge, state.attestation);
|
|
175
176
|
return connected(identity, state);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entrorove/skill",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"entrorove": "cli.mjs"
|
|
@@ -89,6 +89,13 @@
|
|
|
89
89
|
"local-task-handoff-write-receipts.mjs",
|
|
90
90
|
"remote-client.mjs",
|
|
91
91
|
"scope-runner-client.mjs",
|
|
92
|
+
"scope-amendment-client.mjs",
|
|
93
|
+
"scope-amendment-state.mjs",
|
|
94
|
+
"scope-amendment-wire.mjs",
|
|
95
|
+
"scope-amendment-remote.mjs",
|
|
96
|
+
"scope-amendment-observer.mjs",
|
|
97
|
+
"scope-amendment-mutation.mjs",
|
|
98
|
+
"scope-amendment-reference.mjs",
|
|
92
99
|
"cloud-task-client.mjs",
|
|
93
100
|
"task-entry-client.mjs",
|
|
94
101
|
"task-identity.mjs",
|
|
@@ -218,6 +225,7 @@
|
|
|
218
225
|
"ide-install.mjs",
|
|
219
226
|
"install-cleanup.mjs",
|
|
220
227
|
"ide-session.mjs",
|
|
228
|
+
"ide-exit.mjs",
|
|
221
229
|
"break-glass.mjs",
|
|
222
230
|
"ide-results.mjs",
|
|
223
231
|
"ide-local-acceptance.mjs",
|
package/release-state.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "entrorove.release-state/1.0",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"digest": "
|
|
3
|
+
"version": "0.1.60",
|
|
4
|
+
"digest": "e17ae66b1a55cf8a12f750bc8211a083ecb15c6625687e7d17b7ea08a4b302a1",
|
|
5
5
|
"files": {
|
|
6
|
-
"connected-cli.mjs": "
|
|
6
|
+
"connected-cli.mjs": "79c3359303957a9ebb8987e3dfb181f189d380fa02ef4462683b786aedfc7802",
|
|
7
7
|
"local-approved-process.mjs": "442856df788e63f38757439ba0657d1c689aaf797631a955fae3dd639e844e6d",
|
|
8
8
|
"local-approved-supervisor.mjs": "87142a4c13d27c8265cc1fd7fc9088988da9bbb02134164e1a7c5417da926ac7",
|
|
9
9
|
"local-mutation-consumption.mjs": "cd8650b9be894639ceb35af88d23c8002b4acff27afad2e64174eb21a9424cfb",
|
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
"local-preserved-mutation-state.mjs": "337a172b2d0a03b0d5a89b8483afdd9a043a4999fe38f87679633c98c450126b",
|
|
20
20
|
"local-preserved-mutation-wire.mjs": "2f2f623e768d49af59dbb9bb8245627cb7e72184e5a5d3239d91f395152cf9bf",
|
|
21
21
|
"local-delegated-modify-analysis.mjs": "f87148ddb023b79e241ca97db582991da8d0e1a51b26b1d9bc2799a76efd7297",
|
|
22
|
-
"mutation-client.mjs": "
|
|
22
|
+
"mutation-client.mjs": "6455c35b483d6d22e42e7be708630db07d9fd1d3cdf639294b126b69db2eae2f",
|
|
23
23
|
"cloud-mutation-authority.mjs": "c7226e882a4f3be4d2f55728f515d5a815c0aa1f21aa5e4689314e6e6a8c66a2",
|
|
24
24
|
"ide-code-mode.mjs": "f8ba4de33632ab4c6df52dab52f392414b4632c7e94b218b8065677970c075c7",
|
|
25
|
-
"ide-command.mjs": "
|
|
26
|
-
"ide-hook.mjs": "
|
|
27
|
-
"ide-install.mjs": "
|
|
28
|
-
"ide-session.mjs": "
|
|
25
|
+
"ide-command.mjs": "4f61da5a39f88b8ba657d6b1beeb76f765c7f37f3d3e7f52dd9d2eb398499ec3",
|
|
26
|
+
"ide-hook.mjs": "037c245cd2b261acbaed126d5441a2b64a91bc9733a150ebd1ecfdf531d8c49f",
|
|
27
|
+
"ide-install.mjs": "7effa871a44e1215e26b913edc3de0c5b36deaff1a57c0f20d33212160a63f22",
|
|
28
|
+
"ide-session.mjs": "eb8021bc6e38e5c9c35d1dc4cc0015178960cb4eeb39da5a4de47762bf8d7c95",
|
|
29
29
|
"break-glass.mjs": "6ec9f97c5d68bb33121b2374c06b9d4c8eb3419b52d00849fec7124bc63e4582",
|
|
30
30
|
"ide-results.mjs": "baa0618845ced901334644479bf4e0d298bad3f955bc18846752de6c283a5573",
|
|
31
31
|
"ide-local-acceptance.mjs": "238eec6d7b6093fb8ad022da0ed3383a48ba2ffcf693e3ce8a241cf9624088d8",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
|
|
51
51
|
"snapshot-retention.mjs": "ca46aeb36b1a5a24fe7e967031a71b9a3d33f40cb715d344402cbe0dc9c425cb",
|
|
52
52
|
"snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
|
|
53
|
-
"IDE-EXECUTION.md": "
|
|
53
|
+
"IDE-EXECUTION.md": "12bff5711e9100ffcab03409bdf2b587e4a2d3f464500dbaa48ff59786d9cf42",
|
|
54
54
|
"REUSE-DISCIPLINE.md": "5b214cf313e7f715f52d54e28093a2d038f0f196ca64b15d1c49587b9d8eecf8",
|
|
55
55
|
"reuse-discipline-release.mjs": "7f55ae10ec3396ed3bc5430a0c64f94c33c2dfbc57b32084caab92fd5fd1586f",
|
|
56
56
|
"local-final-gate16.mjs": "083aec470670e6cdb7ad2ab27b7a51b53a9ec3a8b4121cb9cbfa17d9940892c2",
|
|
@@ -94,17 +94,18 @@
|
|
|
94
94
|
"local-autocoord17-waits.mjs": "2b0a713987822548a94e1ed3a9a891532db45383797dfc5d5bc8d1e35593cf25",
|
|
95
95
|
"local-validator-isolation17.mjs": "48e94604e28b8fddd567dfb35ff60b73fd094a5803bd49f076388b7f310f92aa",
|
|
96
96
|
"local-calctool-composition17.mjs": "ac89fcb41ab0d0d9b47c1b5696945281b0894eb064820775e36a011c76314e6a",
|
|
97
|
-
"cli.mjs": "
|
|
97
|
+
"cli.mjs": "d0f29034c812f6d2ebfecdfcddacce70334ab379e3d25dc673a72ece817a977c",
|
|
98
|
+
"ide-exit.mjs": "c890c7b34fa6f12e0ccff7a7b85738d515fb13ad93f671a8547ae6570b66488d",
|
|
98
99
|
"install-cleanup.mjs": "8a8e1a7dfe37427f86cd4d6e317890d0829894a9949d4216674a716b36edbc54",
|
|
99
100
|
"review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
|
|
100
101
|
"version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
|
|
101
|
-
"SKILL.md": "
|
|
102
|
-
"CAPABILITY-MAP.md": "
|
|
102
|
+
"SKILL.md": "f6e8b3f64d102063b8e6e263b3422563ed3dd4781c7dd5c46a1c6bccef02ffad",
|
|
103
|
+
"CAPABILITY-MAP.md": "e420bbc202c27d983635ae33b823b39dd445cf5cdf40ffe88e210a755cb7424d",
|
|
103
104
|
"AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
|
|
104
105
|
"DELIVERY-BOUNDARIES.md": "9b9db50ed1bcce9e77ff6527b6bb7eaf12f3b913162ea9cb7fe90db06b393a4f",
|
|
105
|
-
"package.json": "
|
|
106
|
+
"package.json": "5da583a9c348c5acedbd8a97c9ed0b3ba3844bb8b6445c7b3d78690981f612ba",
|
|
106
107
|
"manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
|
|
107
|
-
"release.mjs": "
|
|
108
|
+
"release.mjs": "b404798d90a4447740857a58a7656ecd80b51c36971561d822961913793e101b",
|
|
108
109
|
"local-cli.mjs": "41e2a737c8587cb5ce469a7661f6d14b5ea4cd073f8286822fc4dc96b67f56a1",
|
|
109
110
|
"local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
|
|
110
111
|
"local-execution.mjs": "185f34e917440a360b3c4bafd3af245243807d964ae3820b3cffaaa2e6348cc6",
|
|
@@ -169,7 +170,7 @@
|
|
|
169
170
|
"local-budget-git-system-attributes.mjs": "367a4e5a88490c4d4c3717bc893a271970fe70d7705f0c1c72d6f76a442d5d21",
|
|
170
171
|
"runtime/git-native-linux-v1.mjs": "cf5cb91426a105e62c0f07a8a230dec592dba46f85a3f708b4c4e8cfcd1126c6",
|
|
171
172
|
"runtime/git-native-darwin-v1.mjs": "f88aab72dc86f94eb1115784872957cb8ed0f74205ae70e1ecd1939e7790e310",
|
|
172
|
-
"local-scope-signature.mjs": "
|
|
173
|
+
"local-scope-signature.mjs": "c4c1551c66bb276bf44c66d8e96f3cacc6edac48f6599ad964cd67f5e18110ac",
|
|
173
174
|
"local-scope-config-imports.mjs": "a399407227bbdd14468633bb6d25586df7c6a30a1ef0d1a80ab7c7b499d3fadf",
|
|
174
175
|
"local-scope-config-extends.mjs": "3ab4150b846b247e9e0851221123f220c5574699af651830e751f026ab33659e",
|
|
175
176
|
"local-scope-snapshot.mjs": "c0f851470b98863a1d187825ed61ee6072d1c057b178fdc85ae29af02a3b250f",
|
|
@@ -179,6 +180,13 @@
|
|
|
179
180
|
"remote-client.mjs": "0d1ec6506fe53a1e33c31c5ba19d14e30e00e85e11eaf7ff8277ea798b9f36c9",
|
|
180
181
|
"cloud-task-client.mjs": "11acb1a033c94cdfbdfb177a3402ad97f6a5226f577138c8cf6395dc3ebb1a39",
|
|
181
182
|
"scope-runner-client.mjs": "2afcbb8bc8a26d03f26787cb2ffd9994c4377757d16d54aa0c83752a29e833ea",
|
|
183
|
+
"scope-amendment-client.mjs": "2161daaee131cc209d7ab75cde91a920cbca98153e7e7fd058ba5aacdf6789a3",
|
|
184
|
+
"scope-amendment-state.mjs": "045375115cdca8c265f1bfd14d5cb052fcd24a97c5cc1e50a44a6f6725319698",
|
|
185
|
+
"scope-amendment-wire.mjs": "bc4c80fda3154b7151006f7bed6cc0884050868e7ba936309b6516ed5c20512f",
|
|
186
|
+
"scope-amendment-remote.mjs": "e73bc3e67b8adb663d679cdcc46f0f1a18529d0bf14dd1f6cdf21ad87ead7c71",
|
|
187
|
+
"scope-amendment-observer.mjs": "6c0539e4ac0f738939a169826400d822d68c1e70ca2d87a9fe10a26945158770",
|
|
188
|
+
"scope-amendment-mutation.mjs": "9b120b60aef4d04790a75d599595c5251649b843996f66ebe02e9ac40ffb99b2",
|
|
189
|
+
"scope-amendment-reference.mjs": "0537e53a1bea4b695f4804c5051261778c88f05b1ae4aff86c709dcfe83aa148",
|
|
182
190
|
"native-client.mjs": "6f5575909bae9daba643287f876ffa0f9ff1c53a367b32a40145d740456d6605",
|
|
183
191
|
"native-notification.mjs": "8859a26c6f13d222711f763acf49ed8546b3306cf71f990643ac31a5ccaf1ba1",
|
|
184
192
|
"local-peer-status.mjs": "a971bcc69d60bb32b875ca5bb4297d948631d5d2821b812c279613ea059a8b4f",
|
|
@@ -203,8 +211,8 @@
|
|
|
203
211
|
"local-task-handoff-read-authority.mjs": "7d2991cfac4562039232566d25441aa98106ce27bc9b7bcf6df4ff28d738e167",
|
|
204
212
|
"local-task-handoff-write-archive.mjs": "3668f39d8ab5664ed717994cf1b4bcc7ea23e146a0e06291f851d30337faeb77",
|
|
205
213
|
"local-task-handoff-write-receipts.mjs": "e1321127bd6fcad7840e185639f81ae68a8722a0ee4264a7b504a02b27887647",
|
|
206
|
-
"workflow-local-runner.mjs": "
|
|
207
|
-
"workflow-local-contracts.mjs": "
|
|
214
|
+
"workflow-local-runner.mjs": "dc26f408178f145cbecda7b65add6956c897b952cfa74a77474471d3b97bead1",
|
|
215
|
+
"workflow-local-contracts.mjs": "2ab69043b498dad007e46fa5b4cbda6bffb6720652b970c3dec7f2825175ef87",
|
|
208
216
|
"workflow-local-client.mjs": "45c954f694d96bf0ea87ee2dc6902a6763dac2cb1df713b6a5e749632054e9af",
|
|
209
217
|
"workflow-local-request.mjs": "4e24c543ce386292a87dc9cd41f90d23375f12079cacffc515c8cd01fd571780",
|
|
210
218
|
"task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
|
|
@@ -239,10 +247,10 @@
|
|
|
239
247
|
"local-mutation-files.mjs": "087b00e8424a135e783eb432271012a71e55eeb4a5e30119490d1bc0b2160320",
|
|
240
248
|
"local-mutation-snapshot.mjs": "f9077539680fb8d3ff33a962657756a8fe6afa8409b6191334509534216ebfe8",
|
|
241
249
|
"local-mutation-audit.mjs": "5a30ee3446f632e032ffcfe46bcd97d6dfc0f099a76ea5185c3f9a9118c4f6cb",
|
|
242
|
-
"local-mutation-wire.mjs": "
|
|
250
|
+
"local-mutation-wire.mjs": "fe251b3112d2394c5a0ab108f067ec17fb8569db7be9b3ceb86fab25c68e98e9",
|
|
243
251
|
"local-mutation-binding.mjs": "4440d792453a07a375b635fa5a91db607e58162824baced5c79a9a513f4ec918",
|
|
244
252
|
"local-mutation-pass-store.mjs": "1edc5fcc537dfa04b8ae8b07af554d61ea5911a5da281423ad57214ccf753ad4",
|
|
245
|
-
"local-mutation-authority.mjs": "
|
|
253
|
+
"local-mutation-authority.mjs": "e6b79430ee6b5eb28173843a5faaf1c277da1e5ca7c1dcbd6d96cd60c4460c59",
|
|
246
254
|
"local-mutation-batch.mjs": "de27686b5f99a99b02f0218a3fa159e9469d6b19c595117287d6dd37a6efcb3b",
|
|
247
255
|
"local-mutation-batch-receipt.mjs": "b0e6a8522867255d899e60b66677885933f6a1c4c771cbb52f84dbbbdcf5e5c2",
|
|
248
256
|
"calctool-runtime-entry.mjs": "98d2bc8df38d16131fc56db0a3d4d260531ba4df412baa5ddc4b8b5c5ca99fe9",
|
package/release.mjs
CHANGED
|
@@ -68,7 +68,7 @@ const sourceFiles = [
|
|
|
68
68
|
'local-merge-transaction.mjs',
|
|
69
69
|
...swarmSourceFiles,
|
|
70
70
|
...coordination17SourceFiles,
|
|
71
|
-
'cli.mjs', 'install-cleanup.mjs', 'review-client.mjs', 'version.mjs', 'SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'DELIVERY-BOUNDARIES.md',
|
|
71
|
+
'cli.mjs', 'ide-exit.mjs', 'install-cleanup.mjs', 'review-client.mjs', 'version.mjs', 'SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'DELIVERY-BOUNDARIES.md',
|
|
72
72
|
'package.json', 'manifest.json', 'release.mjs',
|
|
73
73
|
'local-cli.mjs', 'local-state-lock.mjs', 'local-execution.mjs', 'local-integrity.mjs', 'local-evidence-verification.mjs', 'local-formula.mjs', 'local-ledger.mjs',
|
|
74
74
|
'local-error.mjs',
|
|
@@ -118,6 +118,9 @@ const sourceFiles = [
|
|
|
118
118
|
'local-scope-workspaces.mjs',
|
|
119
119
|
'local-task-routing.mjs', 'remote-client.mjs', 'cloud-task-client.mjs',
|
|
120
120
|
'scope-runner-client.mjs',
|
|
121
|
+
'scope-amendment-client.mjs', 'scope-amendment-state.mjs', 'scope-amendment-wire.mjs',
|
|
122
|
+
'scope-amendment-remote.mjs', 'scope-amendment-observer.mjs', 'scope-amendment-mutation.mjs',
|
|
123
|
+
'scope-amendment-reference.mjs',
|
|
121
124
|
'native-client.mjs', 'native-notification.mjs', 'local-peer-status.mjs',
|
|
122
125
|
'cloud-watch-client.mjs', 'cloud-event-stream.mjs', 'ide-presentation.mjs', 'automatic-feedback-client.mjs',
|
|
123
126
|
'task-entry-client.mjs', 'task-identity.mjs', 'task-identity.d.mts',
|
|
@@ -292,6 +295,10 @@ const taskEntryReleaseLabels = {
|
|
|
292
295
|
}
|
|
293
296
|
|
|
294
297
|
function releaseNote(changed) {
|
|
298
|
+
if (changed.some(file => file.startsWith('scope-amendment-'))) return {
|
|
299
|
+
zh: '新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。',
|
|
300
|
+
en: 'Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh.',
|
|
301
|
+
}
|
|
295
302
|
const suppliedZh = process.env.ENTROROVE_RELEASE_NOTE_ZH?.trim()
|
|
296
303
|
const suppliedEn = process.env.ENTROROVE_RELEASE_NOTE_EN?.trim()
|
|
297
304
|
if (Boolean(suppliedZh) !== Boolean(suppliedEn)) {
|
|
@@ -330,6 +337,7 @@ function releaseLabels() {
|
|
|
330
337
|
'blueprint-criterion-wire.mjs': { zh: '蓝图验收标准执行授权', en: 'Blueprint criterion execution authority' },
|
|
331
338
|
'blueprint-criterion-runner.mjs': { zh: '蓝图验收标准实际执行器', en: 'Blueprint criterion process runner' },
|
|
332
339
|
'cli.mjs': { zh: '技能运行和受管安装', en: 'skill runtime and managed installation' },
|
|
340
|
+
'ide-exit.mjs': { zh: '当前 IDE 会话明确退出', en: 'explicit exit for the current IDE session' },
|
|
333
341
|
'install-cleanup.mjs': { zh: '旧版受管安装清理', en: 'managed installation cleanup' },
|
|
334
342
|
'version.mjs': { zh: '版本安全检查', en: 'version safety checks' },
|
|
335
343
|
'SKILL.md': { zh: 'IDE 工作流程说明', en: 'IDE workflow instructions' },
|
package/releases.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.1.60",
|
|
4
|
+
"date": "2026-10-17",
|
|
5
|
+
"zh": "修复范围补正误用独立交接的完整依赖校验,接收原账号签名器的真实受限扫描并保留未解析依赖、原审批与预算;新增当前 IDE 会话离线 exit,识别明确停用决定并保持退出状态,不再要求重建流程。",
|
|
6
|
+
"en": "Fix scope amendments to verify the original member signer wire receipt while preserving unresolved imports, owner approval and native budgets. Add offline exit for the current IDE session, recognize explicit stop decisions and preserve exited governance without requiring a replacement flow."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.1.59",
|
|
10
|
+
"date": "2026-10-17",
|
|
11
|
+
"zh": "新增同一任务的精确范围补正:原账号审核文件哈希与写入/只读角色,原S签名真实事实并重评原合同;计划、写入和结算绑定不可变范围修订,改后刷新保留原M消费证据与失败历史。",
|
|
12
|
+
"en": "Add same-task exact scope amendment with original owner review of file hashes and write/read-only roles, original S observations and native contract reassessment; bind plans, writes and settlement to immutable scope revisions and retain actual M consumption and failure history during post-write refresh."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.1.58",
|
|
4
16
|
"date": "2026-10-17",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
4
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
5
|
+
import { atomicMutationJson14, readMutationJson14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { bootstrapCloudMutationAuthority } from './cloud-mutation-authority.mjs';
|
|
7
|
+
import { amendmentInput, amendmentFail, scopeAmendmentChallenge, scopeRevisionAuthority } from './scope-amendment-wire.mjs';
|
|
8
|
+
import { scopeAmendmentLocation, readScopeAmendmentState, saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
9
|
+
import { amendmentRemote, scopeAmendmentStatus } from './scope-amendment-remote.mjs';
|
|
10
|
+
import { observeScopeAmendment } from './scope-amendment-observer.mjs';
|
|
11
|
+
import { retainScopeAmendmentReference } from './scope-amendment-reference.mjs';
|
|
12
|
+
|
|
13
|
+
const schemaVersion = 'entrorove.scope-amendment-connection/1.0';
|
|
14
|
+
function validateProposal(status, input) {
|
|
15
|
+
const proposal = status.proposal;
|
|
16
|
+
if (!proposal || proposal.proposalId !== input.proposalId || proposal.proposalDigest !== input.proposalDigest
|
|
17
|
+
|| !['pending', 'approved', 'declined'].includes(proposal.approval)) amendmentFail('Current account has no matching original scope proposal');
|
|
18
|
+
return proposal;
|
|
19
|
+
}
|
|
20
|
+
function validateApplied(output, state, location) {
|
|
21
|
+
const effective = output.effectiveAuthority, record = scopeRevisionAuthority(effective?.record, 'entrorove.scope-revision-authority/1.0');
|
|
22
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true }), refresh = challenge.purpose === 'scope-revision-refresh';
|
|
23
|
+
if (output.status !== 'succeeded' || output.grantsExecutionAuthority !== false || output.claimsCompletion !== false
|
|
24
|
+
|| output.scopeRevisionDigest !== sha256(canonical(record)) || effective.revisionDigest !== output.scopeRevisionDigest
|
|
25
|
+
|| output.revision !== effective.revision || record.revision !== effective.revision
|
|
26
|
+
|| ['flowId', 'proposalId', 'proposalDigest', 'ownerId', 'taskDigest', 'chainDigest', 'originalBindingDigest']
|
|
27
|
+
.some(field => record[field] !== challenge[field])
|
|
28
|
+
|| record.scope?.keyId !== location.binding.keyId || record.scope?.runnerId !== location.binding.runnerId
|
|
29
|
+
|| record.scope?.repositoryId !== location.binding.repositoryId
|
|
30
|
+
|| canonical(record.files.map(file => ({ ...file, sha256: null }))) !== canonical(challenge.files.map(file => ({ ...file, sha256: null })))) {
|
|
31
|
+
amendmentFail('Applied scope amendment lost its signed original authority or exact roles');
|
|
32
|
+
}
|
|
33
|
+
const fact = refresh ? scopeRevisionAuthority(effective.fact, 'entrorove.scope-revision-facts/1.0') : record;
|
|
34
|
+
if (refresh && (fact.scopeRevisionDigest !== output.scopeRevisionDigest || fact.factRevision !== challenge.expectedFactRevision
|
|
35
|
+
|| ['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => fact[field] !== challenge[field]))
|
|
36
|
+
|| canonical(fact.candidateAttestation) !== canonical(state.attestation)
|
|
37
|
+
|| fact.scope?.attestationDigest !== sha256(canonical(state.attestation.scopeAttestation))
|
|
38
|
+
|| effective.factRevision !== (refresh ? fact.factRevision : 0) || output.factRevision !== effective.factRevision) {
|
|
39
|
+
amendmentFail('Applied scope proof differs from this exact actual signed candidate');
|
|
40
|
+
}
|
|
41
|
+
return { schemaVersion, status: 'succeeded', flowId: challenge.flowId, proposalId: challenge.proposalId,
|
|
42
|
+
proposalDigest: challenge.proposalDigest, scopeRevisionDigest: output.scopeRevisionDigest, revision: output.revision,
|
|
43
|
+
factRevision: effective.factRevision, grantsExecutionAuthority: false, claimsCompletion: false,
|
|
44
|
+
...(refresh && state.attestation.memberMutation ? { localAction: {
|
|
45
|
+
command: 'npx @entrorove/skill mutation-settle <absolute repositoryRoot>', input: {
|
|
46
|
+
flowId: challenge.flowId, taskId: state.attestation.memberMutation.consumptionReceipt.taskId,
|
|
47
|
+
stepId: state.attestation.memberMutation.consumptionReceipt.stepId, gatePassPath: location.identity.gatePassPath } } } : {}),
|
|
48
|
+
nextStep: output.nextStep, ...(state.feedback ? { feedback: state.feedback } : {}) };
|
|
49
|
+
}
|
|
50
|
+
async function archiveAttempt(location, state) {
|
|
51
|
+
if (!state.attemptId) return;
|
|
52
|
+
const archive = path.join(location.directory, `${state.attemptId}.json`);
|
|
53
|
+
try { if (canonical(await readMutationJson14(archive)) !== canonical(state)) amendmentFail('Original scope amendment attempt history changed'); }
|
|
54
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(archive, state, { privateFrom: location.stateRoot }); }
|
|
55
|
+
for (const key of Object.keys(state)) if (!['schemaVersion', 'identity'].includes(key)) delete state[key];
|
|
56
|
+
await saveScopeAmendmentState(location, state);
|
|
57
|
+
}
|
|
58
|
+
async function finish(location, state, dependencies) {
|
|
59
|
+
if (!state.attestation) {
|
|
60
|
+
state.attestation = await observeScopeAmendment(location, state);
|
|
61
|
+
state.attestationDigest = sha256(canonical(state.attestation)); await saveScopeAmendmentState(location, state);
|
|
62
|
+
}
|
|
63
|
+
const applied = state.applied ?? await amendmentRemote('applied', 'workflow-scope-apply', {
|
|
64
|
+
proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest,
|
|
65
|
+
attestation: state.attestation }, state, location, dependencies);
|
|
66
|
+
const result = validateApplied(applied, state, location);
|
|
67
|
+
await retainScopeAmendmentReference(location, applied); return result;
|
|
68
|
+
}
|
|
69
|
+
async function amend(location, dependencies) {
|
|
70
|
+
const state = await readScopeAmendmentState(location);
|
|
71
|
+
if (state.pending) {
|
|
72
|
+
const pending = state.pending;
|
|
73
|
+
await amendmentRemote(pending.stage, pending.input.operation, pending.input.payload, state, location, dependencies);
|
|
74
|
+
if (pending.stage === 'applied') {
|
|
75
|
+
const result = validateApplied(state.applied, state, location);
|
|
76
|
+
await retainScopeAmendmentReference(location, state.applied); return result;
|
|
77
|
+
}
|
|
78
|
+
return finish(location, state, dependencies);
|
|
79
|
+
}
|
|
80
|
+
const status = await scopeAmendmentStatus(location.identity.flowId, dependencies), proposal = validateProposal(status, location.identity);
|
|
81
|
+
if (proposal.approval !== 'approved') return { schemaVersion, status: proposal.approval === 'pending' ? 'needs-input' : 'blocked',
|
|
82
|
+
flowId: location.identity.flowId, proposalId: proposal.proposalId, proposalDigest: proposal.proposalDigest,
|
|
83
|
+
grantsExecutionAuthority: false, claimsCompletion: false, nextStep: { operation: 'interaction-answer',
|
|
84
|
+
requiresOriginalOwnerApproval: true, scopeExpansionAutomaticallyAuthorized: false } };
|
|
85
|
+
if (state.challenged && !state.applied && !state.rejection) {
|
|
86
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location, { allowExpired: true });
|
|
87
|
+
if (Date.parse(challenge.expiresAt) > Date.now()) return finish(location, state, dependencies);
|
|
88
|
+
state.rejection = { code: 'ENTROROVE_SCOPE_AMENDMENT_CHALLENGE_EXPIRED', effects: 'none',
|
|
89
|
+
reason: 'signed-unused-challenge-expired-before-next-post' }; await saveScopeAmendmentState(location, state);
|
|
90
|
+
}
|
|
91
|
+
await archiveAttempt(location, state);
|
|
92
|
+
state.attemptId = randomUUID(); await saveScopeAmendmentState(location, state);
|
|
93
|
+
const challenged = await amendmentRemote('challenged', 'workflow-scope-challenge', { proposalId: proposal.proposalId,
|
|
94
|
+
proposalDigest: proposal.proposalDigest, receiverId: location.binding.runnerId, attemptId: state.attemptId }, state, location, dependencies);
|
|
95
|
+
const challenge = scopeAmendmentChallenge(challenged.challenge, location);
|
|
96
|
+
if (status.effective ? challenge.purpose !== 'scope-revision-refresh'
|
|
97
|
+
|| challenge.authorityRevisionDigest !== status.effective.revisionDigest || challenge.expectedFactRevision !== status.effective.factRevision + 1
|
|
98
|
+
|| challenge.currentScopeArtifactDigest !== sha256(canonical(status.effective.scope))
|
|
99
|
+
: challenge.purpose !== 'scope-revision' || challenge.expectedRevision !== proposal.expectedRevision
|
|
100
|
+
|| challenge.previousDigest !== proposal.previousDigest) amendmentFail('Scope amendment challenge has a different original authority head');
|
|
101
|
+
return finish(location, state, dependencies);
|
|
102
|
+
}
|
|
103
|
+
export async function runScopeAmend(repositoryRoot, input, dependencies) {
|
|
104
|
+
try {
|
|
105
|
+
const identity = amendmentInput(input);
|
|
106
|
+
await dependencies.install();
|
|
107
|
+
if (!await dependencies.agreementAccepted(dependencies.home)) return dependencies.agreementRequired(dependencies.home);
|
|
108
|
+
await bootstrapCloudMutationAuthority(dependencies);
|
|
109
|
+
const location = await scopeAmendmentLocation(repositoryRoot, identity, dependencies);
|
|
110
|
+
return await withStateLock(location.file, () => amend(location, dependencies));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
const requestId = /^[a-f0-9]{64}$/.test(error?.requestId ?? '') ? error.requestId : undefined;
|
|
113
|
+
return { schemaVersion, status: 'blocked', flowId: input?.flowId,
|
|
114
|
+
code: error?.code?.startsWith('ENTROROVE_') ? error.code : 'ENTROROVE_SCOPE_AMENDMENT_PREREQUISITE',
|
|
115
|
+
message: ['ENTROROVE_SCOPE_AMENDMENT_BINDING', 'ENTROROVE_SCOPE_AMENDMENT_STATE', 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN',
|
|
116
|
+
'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT'].includes(error?.code) ? error.message
|
|
117
|
+
: 'Scope amendment stopped; preserve the original proposal, signer, journal and actual evidence.',
|
|
118
|
+
...(requestId ? { requestId } : {}), ...(error?.output ? { output: error.output } : {}),
|
|
119
|
+
...(error?.feedback ? { feedback: error.feedback } : {}),
|
|
120
|
+
...(error?.outcomeUnknown ? { outcomeUnknown: true, nextAction: { operation: 'scope-amend', requestId,
|
|
121
|
+
method: 'GET', reuseOriginalInput: true, repeatPost: false } } : {}) };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mutationPass14 } from './local-mutation-wire.mjs';
|
|
2
|
+
import { readMutationJson14 } from './local-mutation-files.mjs';
|
|
3
|
+
import { attestMutationConsumption14 } from './local-mutation-consumption.mjs';
|
|
4
|
+
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { sha256 } from './local-integrity.mjs';
|
|
7
|
+
|
|
8
|
+
export async function scopeAmendmentMutation(location, state, challenge, facts) {
|
|
9
|
+
const changed = challenge.files.filter(file => file.access === 'write'
|
|
10
|
+
&& facts.files.find(actual => actual.path === file.path)?.sha256 !== challenge.currentFiles.find(current => current.path === file.path)?.sha256);
|
|
11
|
+
if (!changed.length || challenge.purpose !== 'scope-revision-refresh') return undefined;
|
|
12
|
+
const gatePassPath = location.identity.gatePassPath;
|
|
13
|
+
if (!gatePassPath) amendmentFail('Changed write bytes require the exact original stored mutation pass for actual M consumption proof');
|
|
14
|
+
const gates = path.join(location.stateRoot, 'gates', sha256(location.root).slice(0, 32), challenge.flowId);
|
|
15
|
+
if (path.dirname(gatePassPath) !== gates || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\.json$/.test(path.basename(gatePassPath))) {
|
|
16
|
+
amendmentFail('Scope refresh may read only an original stored pass in this repository and flow');
|
|
17
|
+
}
|
|
18
|
+
const pass = mutationPass14(await readMutationJson14(gatePassPath), { live: false });
|
|
19
|
+
if (['flowId', 'ownerId', 'taskDigest', 'chainDigest'].some(field => pass[field] !== challenge[field])
|
|
20
|
+
|| pass.memberScopeKeyId !== challenge.keyId || pass.memberScopeBindingDigest !== challenge.originalBindingDigest
|
|
21
|
+
|| pass.scopeRevisionDigest !== challenge.authorityRevisionDigest
|
|
22
|
+
|| changed.some(file => !pass.paths.includes(file.path))) amendmentFail('Scope refresh cannot replace its actual original mutation pass or approved write paths');
|
|
23
|
+
const observed = await attestMutationConsumption14({ repositoryRoot: location.root, home: location.home,
|
|
24
|
+
flowId: pass.flowId, taskId: pass.taskId, stepId: pass.stepId, gatePassPath }, { allowExpiredReplay: true });
|
|
25
|
+
return { passId: pass.passId, consumptionReceipt: observed.consumptionReceipt };
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sign, randomUUID } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopeTarget } from './local-scope-files.mjs';
|
|
4
|
+
import { snapshotPlan, snapshotVerify } from './local-execution.mjs';
|
|
5
|
+
import { readScopeSnapshot, verifyFreshScopeSnapshot } from './local-scope-snapshot.mjs';
|
|
6
|
+
import { collectScopeFacts } from './local-scope-repository.mjs';
|
|
7
|
+
import { attestScopeFacts } from './local-scope-signature.mjs';
|
|
8
|
+
import { scopeAmendmentChallenge, amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
|
+
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
10
|
+
import { scopeAmendmentMutation } from './scope-amendment-mutation.mjs';
|
|
11
|
+
|
|
12
|
+
async function inspectFiles(location, challenge) {
|
|
13
|
+
const physical = new Set();
|
|
14
|
+
for (const file of challenge.files) {
|
|
15
|
+
const target = await scopeTarget(location.root, file.path);
|
|
16
|
+
if (!target.stat.isFile()) amendmentFail('Scope amendment permits only exact existing ordinary files');
|
|
17
|
+
const identity = `${target.stat.dev}:${target.stat.ino}`;
|
|
18
|
+
if (physical.has(identity)) amendmentFail('Scope amendment roles cannot alias the same physical file');
|
|
19
|
+
physical.add(identity);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function observeScopeAmendment(location, state) {
|
|
23
|
+
const challenge = scopeAmendmentChallenge(state.challenged.challenge, location);
|
|
24
|
+
await inspectFiles(location, challenge);
|
|
25
|
+
if (!state.snapshotId) { state.snapshotId = `scope-amend-${randomUUID()}`; await saveScopeAmendmentState(location, state); }
|
|
26
|
+
const input = { repositoryRoot: location.root, home: location.home, flowId: challenge.flowId, stepId: challenge.stepId,
|
|
27
|
+
snapshotId: state.snapshotId, paths: challenge.files.map(file => file.path).sort(),
|
|
28
|
+
contractDigest: challenge.proposalDigest, importContexts: location.binding.importContexts };
|
|
29
|
+
if (!state.snapshot) {
|
|
30
|
+
if (!state.snapshotStarted) {
|
|
31
|
+
state.snapshotStarted = true; await saveScopeAmendmentState(location, state);
|
|
32
|
+
try { await snapshotPlan(input); }
|
|
33
|
+
catch (error) { delete state.snapshotStarted; await saveScopeAmendmentState(location, state); throw error; }
|
|
34
|
+
}
|
|
35
|
+
const verified = await snapshotVerify(input);
|
|
36
|
+
if (verified.verified !== true || verified.receipt.contractDigest !== input.contractDigest) amendmentFail('Actual amendment snapshot did not verify');
|
|
37
|
+
state.snapshot = verified.receipt; await saveScopeAmendmentState(location, state);
|
|
38
|
+
}
|
|
39
|
+
const verified = await snapshotVerify(input), snapshot = await readScopeSnapshot(input, location.root);
|
|
40
|
+
if (verified.verified !== true || verified.receipt.snapshotDigest !== state.snapshot.snapshotDigest
|
|
41
|
+
|| snapshot.manifest.contractDigest !== input.contractDigest) amendmentFail('Amendment cannot replace its original candidate snapshot');
|
|
42
|
+
const facts = await collectScopeFacts(location.root, input.paths, undefined, snapshot.known, input);
|
|
43
|
+
facts.snapshotEvidence = await verifyFreshScopeSnapshot(input, location.root, facts);
|
|
44
|
+
for (const file of challenge.files) {
|
|
45
|
+
const actual = facts.files.find(item => item.path === file.path);
|
|
46
|
+
if (!actual?.exists || (challenge.purpose === 'scope-revision' || file.access === 'read-only') && actual.sha256 !== file.sha256) {
|
|
47
|
+
amendmentFail('Scope amendment bytes differ from the exact original approved roles');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const observed = await attestScopeFacts({ ...input, ownerId: challenge.ownerId, taskDigest: challenge.taskDigest,
|
|
51
|
+
chainDigest: challenge.chainDigest, repositoryId: challenge.repositoryId, challenge: challenge.challenge,
|
|
52
|
+
signerConfigPath: location.signerConfigPath }, { repositoryRoot: location.root, facts,
|
|
53
|
+
snapshotDigest: facts.snapshotEvidence.snapshotDigest }, { maximumExpiresAt: Date.parse(challenge.expiresAt) });
|
|
54
|
+
const memberMutation = await scopeAmendmentMutation(location, state, challenge, facts);
|
|
55
|
+
const core = { schemaVersion: 'entrorove.scope-revision-attestation/1.0', purpose: challenge.purpose,
|
|
56
|
+
proposalId: challenge.proposalId, proposalDigest: challenge.proposalDigest, expectedRevision: challenge.expectedRevision,
|
|
57
|
+
previousDigest: challenge.previousDigest, originalBindingDigest: challenge.originalBindingDigest,
|
|
58
|
+
...(challenge.purpose === 'scope-revision-refresh' ? { authorityRevisionDigest: challenge.authorityRevisionDigest,
|
|
59
|
+
expectedFactRevision: challenge.expectedFactRevision } : {}), scopeAttestation: observed.attestation,
|
|
60
|
+
...(memberMutation ? { memberMutation } : {}) };
|
|
61
|
+
if (Date.parse(observed.attestation.expiresAt) <= Date.now()) amendmentFail('Scope amendment expired before signing');
|
|
62
|
+
return { ...core, signature: sign(null, Buffer.from(canonical(core)), location.key).toString('base64url') };
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { mutationHome14, privateMutationDirectory14, readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { scopeRevisionAuthority, amendmentFail } from './scope-amendment-wire.mjs';
|
|
5
|
+
|
|
6
|
+
async function referenceLocation(root, home, flowId, revisionDigest) {
|
|
7
|
+
const stateRoot = path.join(await mutationHome14(home), '.entrorove');
|
|
8
|
+
return { stateRoot, directory: path.join(stateRoot, 'scope-revisions', sha256(root).slice(0, 32), flowId),
|
|
9
|
+
file: path.join(stateRoot, 'scope-revisions', sha256(root).slice(0, 32), flowId, `${revisionDigest}.json`) };
|
|
10
|
+
}
|
|
11
|
+
export async function retainScopeAmendmentReference(location, output) {
|
|
12
|
+
const effective = output.effectiveAuthority, target = await referenceLocation(location.root, location.home,
|
|
13
|
+
location.identity.flowId, output.scopeRevisionDigest);
|
|
14
|
+
const value = { schemaVersion: 'entrorove.scope-amendment-reference/1.0', input: {
|
|
15
|
+
flowId: location.identity.flowId, proposalId: location.identity.proposalId, proposalDigest: location.identity.proposalDigest }, record: effective.record };
|
|
16
|
+
await privateMutationDirectory14(target.directory, { privateFrom: target.stateRoot });
|
|
17
|
+
try { if (canonical(await readMutationJson14(target.file)) !== canonical(value)) amendmentFail('Scope amendment original authority reference changed'); }
|
|
18
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; await atomicMutationJson14(target.file, value, { privateFrom: target.stateRoot }); }
|
|
19
|
+
}
|
|
20
|
+
export async function scopeAmendmentRefreshAction(input, pass) {
|
|
21
|
+
if (!pass.scopeRevisionDigest) return undefined;
|
|
22
|
+
try {
|
|
23
|
+
const target = await referenceLocation(input.repositoryRoot, input.home, pass.flowId, pass.scopeRevisionDigest);
|
|
24
|
+
const reference = await readMutationJson14(target.file);
|
|
25
|
+
const record = scopeRevisionAuthority(reference.record, 'entrorove.scope-revision-authority/1.0');
|
|
26
|
+
if (reference.schemaVersion !== 'entrorove.scope-amendment-reference/1.0' || sha256(canonical(record)) !== pass.scopeRevisionDigest
|
|
27
|
+
|| record.flowId !== pass.flowId || record.taskDigest !== pass.taskDigest || record.scope.keyId !== pass.memberScopeKeyId
|
|
28
|
+
|| ['flowId', 'proposalId', 'proposalDigest'].some(field => reference.input[field] !== record[field])) amendmentFail('Stored refresh reference has another original authority');
|
|
29
|
+
return { command: 'npx @entrorove/skill scope-amend <absolute repositoryRoot>',
|
|
30
|
+
input: { ...reference.input, gatePassPath: input.gatePassPath }, resumeOriginalFlow: true, requiredBefore: 'mutation-settle' };
|
|
31
|
+
} catch {
|
|
32
|
+
return { operation: 'continue', input: { flowId: pass.flowId, operation: 'workflow-scope-status', payload: {} },
|
|
33
|
+
purpose: 'recover-original-scope-amendment-reference', requiredBefore: 'mutation-settle', grantsExecutionAuthority: false };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
|
|
3
|
+
import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
|
|
4
|
+
import { ideClientHeaders } from './ide-presentation.mjs';
|
|
5
|
+
import { acknowledgeAutomaticFeedback, queryAutomaticFeedback, verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
|
|
6
|
+
import { readInvocations } from './local-invocations.mjs';
|
|
7
|
+
import { saveScopeAmendmentState } from './scope-amendment-state.mjs';
|
|
8
|
+
import { amendmentFail } from './scope-amendment-wire.mjs';
|
|
9
|
+
|
|
10
|
+
const unknown = requestId => Object.assign(new Error('Scope amendment outcome is unknown; recover the original request with GET only'),
|
|
11
|
+
{ code: 'ENTROROVE_SCOPE_AMENDMENT_UNKNOWN', outcomeUnknown: true, requestId });
|
|
12
|
+
async function recover(pending, dependencies) {
|
|
13
|
+
try {
|
|
14
|
+
const origin = dependencies.apiOrigin(), token = await dependencies.readCredential(dependencies.home);
|
|
15
|
+
const response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, {
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, ...ideClientHeaders() } });
|
|
17
|
+
const result = parseTransportResponse(response, 'original scope amendment status');
|
|
18
|
+
if (result?.output?.requestId !== pending.requestId || response.status >= 500
|
|
19
|
+
|| ['running', 'pending'].includes(result.output.status)) throw unknown(pending.requestId);
|
|
20
|
+
result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
|
|
21
|
+
verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
|
|
22
|
+
result.feedback = await acknowledgeAutomaticFeedback(result.feedback, origin, token, ideClientHeaders());
|
|
23
|
+
return { response, result };
|
|
24
|
+
} catch { throw unknown(pending.requestId); }
|
|
25
|
+
}
|
|
26
|
+
async function notSent(pending, home) {
|
|
27
|
+
try {
|
|
28
|
+
const rows = (await readInvocations(home)).filter(row => row.kind === 'remote' && row.requestId === pending.requestId);
|
|
29
|
+
return !rows.length || rows.at(-1).event === 'not-sent';
|
|
30
|
+
} catch (error) { return error.code === 'ENOENT'; }
|
|
31
|
+
}
|
|
32
|
+
export async function amendmentRemote(stage, operation, payload, state, location, dependencies) {
|
|
33
|
+
const input = { flowId: location.identity.flowId, operation, payload }, requestId = buildInvocation('continue', input).requestId;
|
|
34
|
+
let response, result;
|
|
35
|
+
if (state.pending) {
|
|
36
|
+
if (state.pending.stage !== stage || state.pending.requestId !== requestId || canonical(state.pending.input) !== canonical(input)) {
|
|
37
|
+
amendmentFail('Recover the original amendment request before another operation');
|
|
38
|
+
}
|
|
39
|
+
({ response, result } = await recover(state.pending, dependencies));
|
|
40
|
+
} else {
|
|
41
|
+
state.pending = { stage, operation: 'continue', input, requestId };
|
|
42
|
+
await saveScopeAmendmentState(location, state);
|
|
43
|
+
try { ({ response, result } = await invokeRemoteResult({ operation: 'continue', input }, dependencies)); }
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (await notSent(state.pending, dependencies.home)) {
|
|
46
|
+
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
47
|
+
throw Object.assign(new Error('Scope amendment was not transmitted; repair its prerequisite and reuse the original input'),
|
|
48
|
+
{ code: 'ENTROROVE_SCOPE_AMENDMENT_NOT_SENT', knownBeforeTransmission: true, requestId });
|
|
49
|
+
}
|
|
50
|
+
throw unknown(requestId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (!result?.output || result.output.requestId !== requestId) throw unknown(requestId);
|
|
54
|
+
const output = result.output;
|
|
55
|
+
if (result.feedback) state.feedback = result.feedback;
|
|
56
|
+
if (!response.ok || !['succeeded', 'needs-input'].includes(output.status)) {
|
|
57
|
+
state.rejection = { requestId, output, ...(result.feedback ? { feedback: result.feedback } : {}) };
|
|
58
|
+
delete state.pending; await saveScopeAmendmentState(location, state);
|
|
59
|
+
throw Object.assign(new Error('The original scope amendment was rejected; preserve its exact proposal and evidence'),
|
|
60
|
+
{ code: output.code ?? 'ENTROROVE_SCOPE_AMENDMENT_REJECTED', requestId, output,
|
|
61
|
+
...(result.feedback ? { feedback: result.feedback } : {}) });
|
|
62
|
+
}
|
|
63
|
+
const value = output.result ?? output;
|
|
64
|
+
state[stage] = value; delete state.pending;
|
|
65
|
+
await saveScopeAmendmentState(location, state); return value;
|
|
66
|
+
}
|
|
67
|
+
export async function scopeAmendmentStatus(flowId, dependencies) {
|
|
68
|
+
try {
|
|
69
|
+
const { response, result } = await invokeRemoteResult({ operation: 'continue',
|
|
70
|
+
input: { flowId, operation: 'workflow-scope-status', payload: {} } }, dependencies);
|
|
71
|
+
if (!response.ok || result?.output?.status !== 'succeeded') throw new Error();
|
|
72
|
+
return result.output.result ?? result.output;
|
|
73
|
+
} catch { amendmentFail('Current account cannot read its original live scope revision; no amendment mutation was sent'); }
|
|
74
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
5
|
+
import { scopeRoot, scopePath, scopeFail } from './local-scope-files.mjs';
|
|
6
|
+
import { retainedScope } from './local-scope-state.mjs';
|
|
7
|
+
import { mutationHome14, mutationFile14, privateMutationDirectory14,
|
|
8
|
+
readMutationJson14, atomicMutationJson14 } from './local-mutation-files.mjs';
|
|
9
|
+
import { trustedOrigin } from './remote-transport.mjs';
|
|
10
|
+
|
|
11
|
+
const fail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_STATE', message);
|
|
12
|
+
const maximumStateBytes = 1_000_000;
|
|
13
|
+
async function privateItem(file, final = 'file') {
|
|
14
|
+
const value = await mutationFile14(file, { final });
|
|
15
|
+
if (process.getuid && value.stat.uid !== process.getuid()
|
|
16
|
+
|| process.platform !== 'win32' && value.stat.mode & 0o077
|
|
17
|
+
|| final === 'file' && (value.stat.nlink !== 1 || value.stat.size > maximumStateBytes)) {
|
|
18
|
+
fail('Scope amendment controls must be bounded private files owned by the current OS account');
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
async function privateText(file) {
|
|
23
|
+
const before = await privateItem(file), handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
24
|
+
try {
|
|
25
|
+
const opened = await handle.stat(), text = await handle.readFile('utf8'), after = await handle.stat();
|
|
26
|
+
const latest = await privateItem(file);
|
|
27
|
+
if ([opened, after, latest.stat].some(stat => ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs', 'mode']
|
|
28
|
+
.some(field => stat[field] !== before.stat[field]))) fail('Original scope signer changed while reading');
|
|
29
|
+
return text;
|
|
30
|
+
} finally { await handle.close(); }
|
|
31
|
+
}
|
|
32
|
+
const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
&& Object.keys(value).sort().join('\0') === [...fields].sort().join('\0');
|
|
34
|
+
export async function scopeAmendmentLocation(repositoryRoot, input, dependencies) {
|
|
35
|
+
const root = await scopeRoot(repositoryRoot), home = await mutationHome14(dependencies.home);
|
|
36
|
+
const relative = path.relative(root, home);
|
|
37
|
+
if (!relative || relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
38
|
+
fail('Scope amendment controls must be outside the observed repository');
|
|
39
|
+
}
|
|
40
|
+
const apiOrigin = trustedOrigin(dependencies.apiOrigin()), stateRoot = path.join(home, '.entrorove');
|
|
41
|
+
const base = path.join(stateRoot, 'scope-runners', sha256(`${apiOrigin}:${input.flowId}:${root}`));
|
|
42
|
+
const controls = path.join(base, 'identity');
|
|
43
|
+
for (const directory of [stateRoot, path.dirname(base), base, controls]) await privateItem(directory, 'directory');
|
|
44
|
+
const binding = JSON.parse(await privateText(path.join(controls, 'binding.json')));
|
|
45
|
+
if (!exact(binding, ['schemaVersion', 'flowId', 'repositoryRoot', 'apiOrigin', 'targetPaths', 'importContexts',
|
|
46
|
+
'runnerId', 'repositoryId', 'publicKey', 'keyId']) || binding.schemaVersion !== 'entrorove.local-scope-binding/1.0'
|
|
47
|
+
|| binding.flowId !== input.flowId || binding.repositoryRoot !== root || binding.apiOrigin !== apiOrigin
|
|
48
|
+
|| !Array.isArray(binding.targetPaths) || !binding.targetPaths.length
|
|
49
|
+
|| canonical(binding.targetPaths) !== canonical([...new Set(binding.targetPaths.map(scopePath))].sort())
|
|
50
|
+
|| !Array.isArray(binding.importContexts)) fail('The original scope signer, entry paths or import contexts are unavailable');
|
|
51
|
+
for (const context of binding.importContexts) {
|
|
52
|
+
if (!exact(context, ['sourcePrefix', 'configPath'])) fail('Original import context is invalid');
|
|
53
|
+
scopePath(context.sourcePrefix); scopePath(context.configPath);
|
|
54
|
+
}
|
|
55
|
+
const signerConfigPath = path.join(controls, 'signer.json'), config = JSON.parse(await privateText(signerConfigPath));
|
|
56
|
+
if (!exact(config, ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'])
|
|
57
|
+
|| config.schemaVersion !== 'entrorove.scope-signer/1.0' || config.privateKeyPath !== path.join(controls, 'private.pem')
|
|
58
|
+
|| config.keyId !== binding.keyId || !Number.isSafeInteger(config.receiptTtlMs)
|
|
59
|
+
|| config.receiptTtlMs < 1 || config.receiptTtlMs > 300_000) fail('Original scope signer configuration changed');
|
|
60
|
+
let key;
|
|
61
|
+
try { key = createPrivateKey(await privateText(config.privateKeyPath)); }
|
|
62
|
+
catch (error) { if (error.code?.startsWith('ENTROROVE_')) throw error; fail('Original scope signing key is unavailable'); }
|
|
63
|
+
const der = createPublicKey(key).export({ type: 'spki', format: 'der' });
|
|
64
|
+
if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== binding.publicKey
|
|
65
|
+
|| sha256(der) !== binding.keyId) fail('Original scope signing identity changed');
|
|
66
|
+
const originalStatePath = path.join(stateRoot, 'scope', sha256(root).slice(0, 32), input.flowId, 'state.json');
|
|
67
|
+
await privateItem(originalStatePath);
|
|
68
|
+
const saved = await readMutationJson14(originalStatePath);
|
|
69
|
+
const original = await retainedScope({ repositoryRoot: root, home, flowId: input.flowId,
|
|
70
|
+
stepId: saved.stepId, snapshotId: saved.originalSnapshot?.snapshotId }, root);
|
|
71
|
+
if (canonical(original.originalFacts.files.map(file => file.path)) !== canonical(binding.targetPaths)) {
|
|
72
|
+
fail('Original retained scope no longer matches its frozen entry paths');
|
|
73
|
+
}
|
|
74
|
+
const identity = { ...input, repositoryRoot: root, home, apiOrigin, localBindingDigest: sha256(canonical(binding)),
|
|
75
|
+
originalFactsDigest: original.originalFactsDigest, originalSnapshotDigest: original.originalSnapshot.digest };
|
|
76
|
+
const directory = path.join(base, 'amendments', sha256(canonical(input)));
|
|
77
|
+
await privateMutationDirectory14(directory, { privateFrom: stateRoot });
|
|
78
|
+
return { root, home, stateRoot, directory, file: path.join(directory, 'journal.json'), identity,
|
|
79
|
+
binding, key, signerConfigPath, original };
|
|
80
|
+
}
|
|
81
|
+
export async function readScopeAmendmentState(location) {
|
|
82
|
+
let value;
|
|
83
|
+
try { await privateItem(location.file); value = await readMutationJson14(location.file); }
|
|
84
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
85
|
+
if (!value) return { schemaVersion: 'entrorove.scope-amendment-state/1.0', identity: location.identity };
|
|
86
|
+
if (value.schemaVersion !== 'entrorove.scope-amendment-state/1.0'
|
|
87
|
+
|| canonical(value.identity) !== canonical(location.identity)) fail('Scope amendment journal cannot replace its original task or signer');
|
|
88
|
+
if (value.attestation && value.attestationDigest !== sha256(canonical(value.attestation))) fail('Stored amendment proof changed');
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
export async function saveScopeAmendmentState(location, value) {
|
|
92
|
+
if (Buffer.byteLength(canonical(value)) > maximumStateBytes) fail('Scope amendment state exceeds its bounded private file limit');
|
|
93
|
+
return atomicMutationJson14(location.file, value, { privateFrom: location.stateRoot, replace: true });
|
|
94
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createPublicKey, verify } from 'node:crypto';
|
|
2
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
|
+
import { scopePath, scopeFail } from './local-scope-files.mjs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const identifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
7
|
+
const hash = /^[a-f0-9]{64}$/;
|
|
8
|
+
export const amendmentFail = message => scopeFail('ENTROROVE_SCOPE_AMENDMENT_BINDING', message);
|
|
9
|
+
export function amendmentExact(value, fields) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
11
|
+
|| Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) amendmentFail('Scope amendment fields are invalid');
|
|
12
|
+
}
|
|
13
|
+
export function amendmentInput(input) {
|
|
14
|
+
amendmentExact(input, ['flowId', 'proposalId', 'proposalDigest', ...['gatePassPath'].filter(field => Object.hasOwn(input ?? {}, field))]);
|
|
15
|
+
if (!identifier.test(input.flowId) || !identifier.test(input.proposalId) || !hash.test(input.proposalDigest)) {
|
|
16
|
+
amendmentFail('Scope amendment requires its exact original flow and proposal identity');
|
|
17
|
+
}
|
|
18
|
+
if (Object.hasOwn(input, 'gatePassPath') && !path.isAbsolute(input.gatePassPath ?? '')) amendmentFail('Scope refresh requires its exact absolute original stored mutation pass');
|
|
19
|
+
return { ...input };
|
|
20
|
+
}
|
|
21
|
+
export function amendmentFiles(files, original) {
|
|
22
|
+
if (!Array.isArray(files) || !files.length || files.length > 512) amendmentFail('Scope amendment requires a bounded complete exact role list');
|
|
23
|
+
const requirements = new Set(original?.requirements?.length ? original.requirements.map(item => item.id) : ['task-goal']);
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
amendmentExact(file, ['path', 'sha256', 'access', 'requirementRefs', 'reason']); scopePath(file.path);
|
|
26
|
+
if (!hash.test(file.sha256) || !['write', 'read-only'].includes(file.access)
|
|
27
|
+
|| typeof file.reason !== 'string' || !file.reason.trim() || file.reason.length > 2000
|
|
28
|
+
|| !Array.isArray(file.requirementRefs) || !file.requirementRefs.length || file.requirementRefs.length > 512
|
|
29
|
+
|| new Set(file.requirementRefs).size !== file.requirementRefs.length
|
|
30
|
+
|| file.requirementRefs.some(id => !identifier.test(id) || !requirements.has(id))) amendmentFail('Scope amendment role or original requirement changed');
|
|
31
|
+
}
|
|
32
|
+
if (new Set(files.map(file => file.path)).size !== files.length) amendmentFail('Scope amendment file roles must be unique');
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
export function scopeRevisionAuthority(value, schemaVersion) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schemaVersion !== schemaVersion) {
|
|
37
|
+
amendmentFail('Scope amendment requires its signed server authority');
|
|
38
|
+
}
|
|
39
|
+
const { authorityReceipt: receipt, ...core } = value, bytes = Buffer.from(canonical(core));
|
|
40
|
+
let key, der;
|
|
41
|
+
try {
|
|
42
|
+
const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY;
|
|
43
|
+
der = Buffer.from(encoded ?? '', 'base64'); key = createPublicKey({ key: der, format: 'der', type: 'spki' });
|
|
44
|
+
if (key.asymmetricKeyType !== 'ed25519' || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded) throw new Error();
|
|
45
|
+
} catch { amendmentFail('Pinned workflow public authority is unavailable'); }
|
|
46
|
+
amendmentExact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature']);
|
|
47
|
+
if (bytes.length > 1_000_000 || receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0'
|
|
48
|
+
|| receipt.keyId !== sha256(der) || receipt.payloadDigest !== sha256(bytes)
|
|
49
|
+
|| typeof receipt.signature !== 'string' || !/^[A-Za-z0-9_-]{86}$/.test(receipt.signature)
|
|
50
|
+
|| Buffer.from(receipt.signature, 'base64url').toString('base64url') !== receipt.signature
|
|
51
|
+
|| !verify(null, bytes, key, Buffer.from(receipt.signature, 'base64url'))) amendmentFail('Scope amendment authority signature changed');
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
export function scopeAmendmentChallenge(value, location, { allowExpired = false } = {}) {
|
|
55
|
+
const challenge = scopeRevisionAuthority(value, 'entrorove.scope-revision-challenge/1.0');
|
|
56
|
+
const refresh = challenge.purpose === 'scope-revision-refresh';
|
|
57
|
+
if (!refresh && challenge.purpose !== 'scope-revision') amendmentFail('Scope amendment challenge has another purpose');
|
|
58
|
+
const common = ['flowId', 'proposalId', 'proposalDigest'];
|
|
59
|
+
if (common.some(field => challenge[field] !== location.identity[field])
|
|
60
|
+
|| challenge.runnerId !== location.binding.runnerId || challenge.repositoryId !== location.binding.repositoryId
|
|
61
|
+
|| challenge.keyId !== location.binding.keyId || challenge.receiverId !== location.binding.runnerId
|
|
62
|
+
|| challenge.taskDigest !== location.original.taskDigest || canonical(challenge.task) !== canonical(location.original.originalTask)
|
|
63
|
+
|| !identifier.test(challenge.ownerId) || !identifier.test(challenge.stepId) || !identifier.test(challenge.challengeId)
|
|
64
|
+
|| !identifier.test(challenge.challenge) || !Number.isSafeInteger(challenge.expectedRevision) || challenge.expectedRevision < 0
|
|
65
|
+
|| ['previousDigest', 'originalBindingDigest', 'originalGateDigest', 'baseScopeArtifactDigest', 'chainDigest'].some(field => !hash.test(challenge[field]))) {
|
|
66
|
+
amendmentFail('Scope amendment challenge replaced its original task, signer or scope identity');
|
|
67
|
+
}
|
|
68
|
+
amendmentFiles(challenge.files, location.original.originalTask);
|
|
69
|
+
if (!Array.isArray(challenge.currentFiles) || challenge.currentFiles.length !== challenge.files.length
|
|
70
|
+
|| !hash.test(challenge.currentScopeArtifactDigest)
|
|
71
|
+
|| new Set(challenge.currentFiles.map(file => file.path)).size !== challenge.files.length) amendmentFail('Scope amendment current facts are invalid');
|
|
72
|
+
for (const current of challenge.currentFiles) {
|
|
73
|
+
amendmentExact(current, ['path', 'sha256']);
|
|
74
|
+
if (!challenge.files.some(file => file.path === current.path) || !hash.test(current.sha256)) amendmentFail('Scope amendment current facts replaced its exact roles');
|
|
75
|
+
}
|
|
76
|
+
if (location.binding.targetPaths.some(path => !challenge.files.some(file => file.path === path))) amendmentFail('Scope amendment omitted an original entry');
|
|
77
|
+
const issued = Date.parse(challenge.issuedAt), expires = Date.parse(challenge.expiresAt);
|
|
78
|
+
if (!Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() || !allowExpired && expires <= Date.now()
|
|
79
|
+
|| expires <= issued || expires - issued > 90_000) amendmentFail('Scope amendment challenge is expired');
|
|
80
|
+
if (refresh && (!hash.test(challenge.authorityRevisionDigest) || challenge.authorityRevisionDigest !== challenge.previousDigest
|
|
81
|
+
|| !Number.isSafeInteger(challenge.expectedFactRevision) || challenge.expectedFactRevision < 1)) amendmentFail('Scope proof refresh lost its unchanged authority revision');
|
|
82
|
+
return challenge;
|
|
83
|
+
}
|
|
@@ -48,6 +48,7 @@ export const WORKFLOW_ACCEPTED_PLAN_SCHEMA = exact({ schemaVersion: { const: 'en
|
|
|
48
48
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.verificationMode = enumeration(['independent', 'member-local']);
|
|
49
49
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeKeyId = sha256;
|
|
50
50
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeBindingDigest = sha256;
|
|
51
|
+
WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.scopeRevisionDigest = sha256;
|
|
51
52
|
WORKFLOW_ACCEPTED_PLAN_SCHEMA.allOf = [{
|
|
52
53
|
if: { properties: { verificationMode: { const: 'member-local' } }, required: ['verificationMode'] },
|
|
53
54
|
then: { properties: { runnerKeyId: { type: 'null' } }, required: ['memberScopeKeyId', 'memberScopeBindingDigest'] },
|
|
@@ -63,6 +64,7 @@ export const WORKFLOW_LOCAL_LEASE_SCHEMA = exact({ schemaVersion: { const: 'entr
|
|
|
63
64
|
verifiedDependencies: { ...boundedArray(exact({ stepId: id, evidenceDigest: sha256 }), 0, 128), uniqueItems: true }, authorityReceipt,
|
|
64
65
|
});
|
|
65
66
|
WORKFLOW_LOCAL_LEASE_SCHEMA.properties.localDependencies = { ...boundedArray(exact({ stepId: id, acceptanceDigest: sha256 }), 0, 128), uniqueItems: true };
|
|
67
|
+
WORKFLOW_LOCAL_LEASE_SCHEMA.properties.scopeRevisionDigest = sha256;
|
|
66
68
|
WORKFLOW_LOCAL_LEASE_SCHEMA.allOf = [{ if: { required: ['localDependencies'] },
|
|
67
69
|
then: { properties: { verifiedDependencies: { type: 'array', maxItems: 0 } } } }];
|
|
68
70
|
export const WORKFLOW_LOCAL_OPERATION_SCHEMA = object({ repositoryRoot: absolutePath, home: absolutePath,
|
|
@@ -64,7 +64,7 @@ function lifetime(start, end, max, now, permitExpired = false) {
|
|
|
64
64
|
}
|
|
65
65
|
function validatePlan(plan) {
|
|
66
66
|
exact(plan, ['schemaVersion', 'planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision', 'acceptedAt', 'expiresAt', 'runnerKeyId', 'steps',
|
|
67
|
-
...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'].filter(field => Object.hasOwn(plan, field)),
|
|
67
|
+
...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeRevisionDigest'].filter(field => Object.hasOwn(plan, field)),
|
|
68
68
|
...(plan.blueprintBinding ? ['blueprintBinding'] : [])], 'ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan');
|
|
69
69
|
if (plan.blueprintBinding) {
|
|
70
70
|
const binding = exact(plan.blueprintBinding, ['schemaVersion', 'blueprintId', 'revision', 'blueprintSha256', 'machineTasksDigest'],
|
|
@@ -80,6 +80,7 @@ function validatePlan(plan) {
|
|
|
80
80
|
: (plan.verificationMode === undefined || plan.verificationMode === 'independent') && SHA.test(plan.runnerKeyId ?? '')
|
|
81
81
|
&& !Object.hasOwn(plan, 'memberScopeKeyId') && !Object.hasOwn(plan, 'memberScopeBindingDigest');
|
|
82
82
|
if (plan.schemaVersion !== WORKFLOW_LOCAL_PLAN_SCHEMA || !SHA.test(plan.taskDigest) || !SHA.test(plan.chainDigest)
|
|
83
|
+
|| Object.hasOwn(plan, 'scopeRevisionDigest') && !SHA.test(plan.scopeRevisionDigest)
|
|
83
84
|
|| !validRole || !positive(plan.chainRevision) || !Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > 128) fail('ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan contract is invalid');
|
|
84
85
|
for (const field of ['planId', 'flowId', 'ownerId']) safeId(plan[field], field);
|
|
85
86
|
const seen = new Set();
|
|
@@ -97,11 +98,12 @@ function validatePlan(plan) {
|
|
|
97
98
|
}
|
|
98
99
|
function validateLease(lease, plan, stepId) {
|
|
99
100
|
exact(lease, ['schemaVersion', 'leaseId', 'planId', 'planDigest', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision',
|
|
100
|
-
'stepId', 'sequence', 'generation', 'issuedAt', 'expiresAt', 'verifiedDependencies'
|
|
101
|
+
'stepId', 'sequence', 'generation', 'issuedAt', 'expiresAt', 'verifiedDependencies',
|
|
102
|
+
...['scopeRevisionDigest'].filter(field => Object.hasOwn(lease, field))], 'ENTROROVE_WORKFLOW_BINDING', 'local lease');
|
|
101
103
|
safeId(lease.leaseId, 'leaseId'); safeId(stepId, 'stepId');
|
|
102
104
|
const step = plan.steps.find(item => item.stepId === stepId);
|
|
103
105
|
if (!step || lease.schemaVersion !== WORKFLOW_LOCAL_LEASE_SCHEMA || lease.stepId !== stepId || lease.sequence !== step.sequence
|
|
104
|
-
|| lease.planDigest !== sha(canonical(plan)) || !positive(lease.generation)
|
|
106
|
+
|| lease.planDigest !== sha(canonical(plan)) || !positive(lease.generation) || lease.scopeRevisionDigest !== plan.scopeRevisionDigest
|
|
105
107
|
|| ['planId', 'flowId', 'ownerId', 'taskDigest', 'chainDigest', 'chainRevision'].some(key => lease[key] !== plan[key])) fail('ENTROROVE_WORKFLOW_BINDING', 'lease does not match the exact original plan, task, chain, owner and step');
|
|
106
108
|
if (!Array.isArray(lease.verifiedDependencies) || lease.verifiedDependencies.length !== step.dependsOn.length) fail('ENTROROVE_WORKFLOW_DEPENDENCY', 'cloud verified dependency receipts are required');
|
|
107
109
|
const dependencies = lease.verifiedDependencies.map(item => {
|