@entrorove/skill 0.1.56 → 0.1.58

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  | --- | --- | --- |
7
7
  | 成员受限编辑 | `mutation-connect <repositoryRoot>`、`local mutation-delegate-prepare` | 原任务、冻结修改声明及租约绑定;观察、编辑、独立验证三把密钥分离。运行时依赖保持未知且只读,只允许现存文件的受限修改;不授予通用执行、合并或交付权限 |
8
8
  | 连续编辑结算 | `mutation-settle <repositoryRoot>`、`local mutation-consumption-attest`、服务端 `mutation-pass-settle` | 实际写后 M 回执与 fresh S 后验共同确认原 pass;下一轮使用 `previousPassId`,不伪造架构验证事件 |
9
- | 沙箱关闭后的本地测试 | `workflow-observe-step <repositoryRoot>`、`member-observation-admit/report/status` | 仅运行原冻结命令;写入步骤对照已结算源文件,只读步骤对照原 W 计划的签名源文件且无需 M 结算。结果独立保存为 local / pending-independent,不升格为 V 验证或交付 |
9
+ | 沙箱关闭后的本地验收 | `workflow-observe-step <repositoryRoot>`、`member-observation-admit/report/status`、`workflow-verdict` | 服务器签名 member-local 计划中,原冻结命令和测试真实通过后签发独立保存的 localAcceptance,续跑原步骤并完成本地验收;原 O 无 V 回执,明确未进行独立验证。原范围、审批、S/M 分离及未知依赖保护保留 |
10
10
  | 续跑入口 | 原 `execution-claim`、原 flow 恢复 | 成员受限范围返回原字段的 `localAction`,客户端串联真实快照、签名、云端通行证及本地安装;网络结果不明时恢复原请求 |
11
11
  | 同一阻断停止 | IDE `Stop` 回调 | 真实重复阻断可等待;状态查询及检查点不能重置阻断,宿主重复 Stop 不再生成无限续跑。原 flow、需求队列与恢复数据保留 |
12
12
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.58 — 2026-10-17
4
+
5
+ - 修复截图等多模态 PostToolUse 事件超过旧输入上限时被误拦:完整读取并严格校验最多 32 MiB 的 UTF-8 JSON 对象,超限或无效事件返回固定安全诊断;保留原授权、待回执调用及 O 证据,私有状态文件仍限 1,000,000 字节。
6
+ - Fixed multimodal PostToolUse events such as screenshots being rejected by the old input limit. Read and strictly validate the complete UTF-8 JSON object up to 32 MiB, returning fixed safe diagnostics for oversized or invalid events while preserving original authority, pending calls and O evidence. Private state files retain their 1,000,000-byte limit.
7
+
8
+ ## 0.1.57 — 2026-10-17
9
+
10
+ - 云端关闭时以原本地 IDE 执行与实际测试完成本地验收,补齐签名步骤续跑、最终验收、钩子与队列恢复;保留原范围、审批和 S/M 证据,明确未做独立验证。修复原规划字段诊断与沙箱模式恢复。
11
+ - Completed explicit local IDE acceptance with real execution and tests when cloud sandbox is disabled, including signed step progress, final acceptance, hooks and queue recovery while preserving original scope, approvals and S/M evidence without claiming independent verification. Added precise planning diagnostics and original sandbox-mode recovery.
12
+
3
13
  ## 0.1.56 — 2026-10-17
4
14
 
5
15
  - 补齐原工作流显式只读步骤及无写入结算的本地观察,复核真实受检仓库字节与权限;修复流程列表空输入兼容,返回精确规划前置条件与范围错误,并配置 GitHub OIDC 发布。
package/IDE-EXECUTION.md CHANGED
@@ -1,5 +1,7 @@
1
1
  Install or update the managed copy with `npx @entrorove/skill install`. In Codex, use `npx @entrorove/skill install --host codex` to configure Codex only; this leaves Claude Code hook settings and skill copies alone. The installer keeps one global managed entrorove skill, replaces its older pristine version, removes only pristine duplicate installations for the selected host, and preserves hand-edited files. An older package cannot overwrite a newer managed version.
2
2
 
3
+ Host hook events, including multimodal `PostToolUse` screenshot results, accept at most 32 MiB (33,554,432 UTF-8 bytes). The complete bounded event is decoded once and follows the original session, tool-call and signature checks; image results do not grant permissions or erase a pending call. Oversized events return `ENTROROVE_IDE_INPUT_TOO_LARGE`; invalid UTF-8 or JSON objects return `ENTROROVE_IDE_INPUT_INVALID_JSON`. These fixed diagnostics do not include the event contents. Private IDE state JSON files retain their separate 1,000,000-byte limit. A hook error after a tool action does not prove the action was unexecuted: inspect the original result or status before considering another attempt.
4
+
3
5
  Register or sign in by email at https://entrorove.com. Enter the account key once with `npx @entrorove/skill configure` and `{"token":"..."}` on standard input. The credential persists in the current OS user's restricted local store across skill upgrades. Before asking again, use the stored credential; a missing or invalid credential must be reported explicitly. Never place the key in arguments, URLs, project files or logs. entrorove accounts and keys are independent of other services.
4
6
 
5
7
  If the IDE guard itself is the blocked step, run `entrorove unlock` directly from the affected IDE terminal. The command is offline and records a 15-minute lease bound to that IDE session; it does not call a hook, read a credential, accept the agreement, inspect the protocol or authorize project writes. The host session ID must be present in the environment or supplied as `ENTROROVE_SESSION`. Under the lease, `flow-cancel` and `flow-abandon` recover the original flow; successful `flow-abandon` releases its task binding in both the flow state and the IDE session record, even without a hook callback. Queued requests remain, and other IDE sessions stay guarded.
@@ -36,7 +38,7 @@ Set `ENTROROVE_IDE` to the actual host name (for example `Codex`, `Cursor` or `C
36
38
 
37
39
  ### Interrupting and resuming requirements
38
40
 
39
- User text received during an unfinished governed task is persisted as an exact, private queue entry. `queue status` reads it; `queue route` assigns the existing `background`, `normal`, `high` or `urgent` priority. `interrupt` only takes effect at a controlled-call checkpoint and keeps the parent flow, task ID, task number, request ID, repository and account binding. Once the inserted requirement has a verified completion and stored feedback acknowledgement, the parent is restored and its returned `nextStep` is the only continuation. Stop may request one bounded continuation to classify a queued requirement or recover an unresolved request read only. A persisted repeated blocker may wait without terminating or advancing the original queue; automatic continuation is limited to one attempt per user turn and respects the host's `stop_hook_active` signal. Status queries and checkpoints do not erase the blocker. The queue does not promise execution when the host is powered off or the IDE process is unavailable.
41
+ User text received during an unfinished governed task is persisted as an exact, private queue entry. `queue status` reads it; `queue route` assigns the existing `background`, `normal`, `high` or `urgent` priority. `interrupt` only takes effect at a controlled-call checkpoint and keeps the parent flow, task ID, task number, request ID, repository and account binding. Once the inserted requirement satisfies its original completion contract and stored feedback acknowledgement, the parent is restored and its returned `nextStep` is the only continuation. A member-local task requires the signed final local acceptance; an independent task requires verified completion. Stop may request one bounded continuation to classify a queued requirement or recover an unresolved request read only. A persisted repeated blocker may wait without terminating or advancing the original queue; automatic continuation is limited to one attempt per user turn and respects the host's `stop_hook_active` signal. Status queries and checkpoints do not erase the blocker. The queue does not promise execution when the host is powered off or the IDE process is unavailable.
40
42
 
41
43
  ### Delivery boundaries and stop-loss
42
44
 
@@ -92,7 +94,19 @@ After the pass's actual writes finish, rerun the original `scope-connect` with i
92
94
 
93
95
  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.
94
96
 
95
- With cloud sandbox disabled, the returned `observationAction` runs `npx @entrorove/skill workflow-observe-step <original repositoryRoot>`. A mutable step uses `{taskId,flowId,stepId,leaseId,generation,settlementId}` and the service updates source hashes only through accepted M settlements. An explicitly frozen read-only step uses `{taskId,flowId,stepId,leaseId,generation}`; its signed admission declares `sourceKind: original-read-only-plan` and contains no settlement ID or digest. Both paths require the original frozen command, executable hash, environment, limits and current signed source manifest. The local runner checks the actual source and protected dependencies before and after execution, including repository bytes and modes. No validator private key is needed. A real successful process remains `status: incomplete`, `runner: local`, `receipt: null`, `independentValidation: pending`; its result is stored separately and read with `member-observation-status`. It cannot advance formal verified dependencies or task delivery. Reentry preserves a completed process result; an unknown process outcome never authorizes an automatic second launch.
97
+ With cloud sandbox disabled, the returned `observationAction` runs `npx @entrorove/skill workflow-observe-step <original repositoryRoot>`. A mutable step uses `{taskId,flowId,stepId,leaseId,generation,settlementId}` and the service updates source hashes only through accepted M settlements. An explicitly frozen read-only step uses `{taskId,flowId,stepId,leaseId,generation}`; its signed admission declares `sourceKind: original-read-only-plan` and contains no settlement ID or digest. Both paths require the original frozen command, executable hash, environment, limits and current signed source manifest. The local runner checks actual source and protected dependencies before and after execution, including repository bytes and modes. No validator private key is needed. The raw observation stays `status: incomplete`, `runner: local`, `receipt: null`, `independentValidation: pending`; local acceptance is represented separately below. Reentry preserves a completed process result; an unknown process outcome never authorizes an automatic second launch.
98
+
99
+ ### Local IDE acceptance
100
+
101
+ For an enrolled member with the account's cloud sandbox disabled, `workflow-plan` freezes a server-signed `verificationMode: member-local`, `runnerKeyId: null` plan bound to the original scope signer. This is an explicit local acceptance contract. It neither depends on a missing V key nor changes an already frozen independent plan. Both fully known and restricted dependency scopes retain their existing write guards; unknown loaders remain protected.
102
+
103
+ Use each returned original `localAction` and `observationAction`. Only an actual successful original process, passing checks and unchanged protected repository can receive a server-signed `localAcceptance`. The wrapper reports `local-accepted` and `independentValidation: not-performed`, while its raw observation remains intact. The CLI and IDE hook verify that signature and the original request, lease and result binding. Local dependencies carry `localDependencies`; formal `verifiedDependencies` remain empty. A failed process, changed bytes, forged signature or unknown outcome cannot advance the next step.
104
+
105
+ After all original steps and requirement checkpoints have passed, invoke the returned `workflow-verdict`. A signed `entrorove.member-local-workflow-acceptance/1.0` completes local acceptance of that same task. Display “本地执行与测试已验收;未进行独立验证”. Formal V, Blueprint independent criteria, Swarm, merge and handoff reject local plans. Do not generate a validator key, claim independent verification, alter failed history or create a replacement flow.
106
+
107
+ The installed hook verifies the original signed local result before continuing or ending the task. Final acceptance and acknowledged truthful feedback permit restoring its queued parent and cleaning superseded snapshots for this flow, preserving original, final and referenced rollback copies. Cloud resource expiry can clean that resource while a valid original local plan continues; explicit cancellation and already terminal workflows retain their original termination.
108
+
109
+ For `ENTROROVE_PLAN_VALIDATION_REQUIRED`, correct only the identified proposal field and derive a canonical request identity from the corrected content on the same flow. Manifest hashes use `SHA256("validator.file-manifest/1.0\n" + files.map(file => file.path + "\0" + file.sha256).join("\n"))`, without a trailing newline. This is distinct from the canonical JSON digest of the tests array. `ENTROROVE_PLAN_SCOPE_REVIEW_REQUIRED` does not authorize extra existing files or name an executable scope amendment operation; preserve the exact accepted targets and obtain the original scope authority before expanding them.
96
110
 
97
111
  This command runs with the host OS user's permissions, not inside an OS sandbox. Its repository checks do not prove that external files, networks or databases were unchanged: `executionIsolation` remains `host-permissions`, and `externalEffects` remains `unverified`. The independent supervisor enforces the admitted start deadline and command timeout and reclaims its own process group if the CLI disappears. Only the original approved command can be launched; these observations grant no general shell or project-write permission.
98
112
 
@@ -102,7 +116,7 @@ An editing pass does not certify test execution, independent validation, merge o
102
116
 
103
117
  For a decision, include `input.task.interaction` with `type` (`confirm`, `choice`, `multi` or `input`), a `question`, `options` (two or more with unique `id` and `label`, or an empty array for input), and a `callback` containing its `operation` and `payload`. Medium- and high-risk decisions also need a `riskDescription`. The decision waits for an explicit answer; it never chooses a default on timeout.
104
118
 
105
- Use `npx @entrorove/skill status <requestId>` after a connection loss. It performs a read-only GET lookup and never resends a POST. Use `npx @entrorove/skill flows` to list recoverable flow steps. `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per host and model and, with an original flow, its delivered stages; it never displays an invented quota or a predicted duration. `npx @entrorove/skill billing` exports measured cost per account, host, model and task with that task's delivery stages. A declared host adapter may report the host's own remaining quota through the same module that describes its guard; when it cannot, the answer stays explicitly `unknown` and only this skill's measured ledger is used. `GET /api/skill/flows/:id/status` reports `delivery` with delivered, total and remaining stages, counted from independently verified steps only. A v2 flow has its own `flowId` and progresses through `pending`, `running`, `reported`, `verified`, `blocked` or `failed`; a reported result is not a verified result.
119
+ Use `npx @entrorove/skill status <requestId>` after a connection loss. It performs a read-only GET lookup and never resends a POST. Use `npx @entrorove/skill flows` to list recoverable flow steps. `npx @entrorove/skill usage [flowId]` reads measured consumption grouped per host and model and, with an original flow, its delivered stages; it never displays an invented quota or a predicted duration. `npx @entrorove/skill billing` exports measured cost per account, host, model and task with that task's delivery stages. A declared host adapter may report the host's own remaining quota through the same module that describes its guard; when it cannot, the answer stays explicitly `unknown` and only this skill's measured ledger is used. `GET /api/skill/flows/:id/status` reports `delivery` with delivered, total and remaining stages. Independent plans count independently verified steps; member-local plans count only signed local acceptances of original business steps and explicitly report that basis. A v2 flow keeps its original `flowId`; local acceptance does not change formal step status to `verified`.
106
120
 
107
121
  The local execution layer is available through `npx @entrorove/skill local <operation> [repositoryRoot]` with a JSON object on standard input. `local capabilities` lists the complete contract, operation groups and `requiresPass` metadata. `local capabilities <group>` filters that same contract. Operations include `snapshot-plan`, `snapshot-verify`, `mutate-gate`, `gate-issue`, `gate-verify`, `guarded-write`, `budget-init`, `budget-context`, `budget-read`, `budget-status`, `budget-extend`, budget renewal confirmation, `peer-coordinate`, `spawn-bind`, `peer-complete`, `lease-verify`, `probe`, `reassess`, execution ledger and virtual branch records, `chain-init`, `chain-resume`, `chain-status`, `chain-answer`, `evidence-sign`, `evidence-verify`, `merge-verified`, `rollback`, `formula-evaluate`, `formula-graph`, `formula-final-gate`, `run-approved-plan` and `final-gate`. State is kept only under `~/.entrorove/`; snapshots never create Git branches or worktrees. Guarded writes require a short-lived Ed25519 pass bound to the flow, step, snapshot digest and path set. Local evidence uses `entrorove.test-evidence/2.0`; unsigned local evidence remains incomplete and verified delivery requires a trusted receipt. Approved plan execution requires a frozen plan, matching approval and unchanged files; without a configured runner signer it reports local evidence as incomplete.
108
122
 
@@ -122,7 +136,7 @@ The website's signed-in bell control enables encrypted Web Push for the current
122
136
 
123
137
  Every completed or failed execution call receives a server-generated `feedback` receipt bound to the original account, request hash and result digest. Durable calls commit this evaluation in the same transaction as the result; domain adapters return an idempotent receipt with the same HTTP response. The CLI verifies the original identity and evaluation digest, then immediately returns that exact receipt identity to the authenticated acknowledgement endpoint before finishing the call. Cloud watches do the same as soon as the original evaluation is available. A confirmed `acknowledgement` records the IDE and server reception time without changing the evaluation or counting another invocation. Preserve `receiptId`, `requestId` and `evaluation.digest`; never generate a score or comment locally. Immediately display the confirmed evaluation acknowledgement in visible IDE commentary before the task's final answer, deduplicated by receipt ID. Tool output alone is insufficient. Say “已回传” only with a confirmed acknowledgement; an upload failure must remain visible and cannot become successful feedback. Read the saved receipt with `npx @entrorove/skill feedback <original requestId>` after a connection loss; this is GET only and never repeats execution or submits another evaluation. An authorized retry before side effects keeps the original request but gets a new server execution generation and immutable receipt. Default feedback reads the latest generation; `feedback <original requestId> <generation>` reads a preserved earlier receipt. Never overwrite a failed receipt or use it as the current retry result. Discovery/status queries create no evaluations.
124
138
 
125
- Only server-verified progress or a validated pure operation earns a positive automatic evaluation. Reported steps, intake, model summaries and incomplete evidence remain `pending` with no score. Blocked/failed results and P0/P1 findings cannot become positive. These are private technical evaluations; they do not create a public user review or masquerade as the user's words. If a server advertising this extension returns no valid receipt, show the missing receipt and query its original ID. Do not silently claim that evaluation was reported.
139
+ Only server-verified progress, a validated pure operation or a verified server-signed local acceptance earns a positive automatic evaluation. Local evaluations state `verificationMode: member-local` and `independentValidation: not-performed`. Reported steps, intake, model summaries and incomplete evidence without local acceptance remain `pending` with no score. Blocked/failed results and P0/P1 findings cannot become positive. These are private technical evaluations; they do not create a public user review or masquerade as the user's words. If a server advertising this extension returns no valid receipt, show the missing receipt and query its original ID. Do not silently claim that evaluation was reported.
126
140
 
127
141
  When the user gives an actual evaluation after using the skill, offer to publish that feedback. Only after they explicitly agree to public display under the current notice, send their unchanged words, IDE name and 1–5 rating as JSON on standard input to `npx @entrorove/skill review`, including `invocationRequestId` from their actual successful call, `publicConsent: true` and `noticeVersion: "2026-10-07.1"`. The same account must own that successful call within the last 30 days; a recorded usage event, failed call or unknown result does not qualify. Three public reviews per account per day share one server quota across API replicas. Public output exposes only IDE, rating, original text and time, never account or invocation identity. Never invent, rewrite or infer a review from task completion. The website reads accepted reviews directly from entrorove's independent database.
128
142
 
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.56.
8
+ Version: 0.1.58.
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
 
@@ -16,15 +16,15 @@ Entrorove applies to the whole task once selected. Reading this document or call
16
16
  To remove this skill and its host hooks, run `entrorove uninstall` directly. It works offline and preserves unrelated host settings, credentials and task records. If a managed file was edited, it reports the preserved path instead of deleting it.
17
17
  2. Read [IDE-EXECUTION.md](IDE-EXECUTION.md) for the exact entry, cloud callbacks, account agreement and operation contracts. Use the stored account credential; never put it in arguments, URLs, project files, output or fixtures. Entrorove is independent of other systems.
18
18
  3. Discover `capabilities`, then invoke `start` with the actual goal, task type, risk and scope facts. An enabled account returns a cloud choice before charging a model. Show that choice visibly using the host question tool. Confirmation calls its original callback; exit adopts the local route for this task. Never treat silence as permission, invent acceptance, or create both routes. Existing explicit authorization for this task persists.
19
- With cloud sandbox disabled, the IDE performs its own planning and continues the original API flow. Do not require a cloud model call or create a sandbox task to perform local work.
19
+ With cloud sandbox disabled, the IDE performs its own planning and continues the original API flow. Do not require a cloud model call or create a sandbox task to perform local work. The service freezes eligible member plans with `verificationMode: member-local` and `runnerKeyId: null`; successful original IDE execution and tests satisfy local acceptance. This mode comes from the stored account preference and enrolled scope, never a caller flag or an unavailable validator key. An already frozen independent plan keeps its original acceptance contract.
20
20
  When the model produces a task contract, preserve its `workType`, `risk`, `difficulty`, `crossModule`, `parallel`, `unclear`, `architectureContract`, optional `baselineRef`, and optional `interaction` as returned. Valid work types are `implementation`, `documentation`, `calculation`, `merge`, `review`, `decision`, and `diagnosis`. A `merge` requires a concrete `baselineRef` identifying the branch, tag, commit, or review baseline; keep that baseline unchanged through independent verification. A `decision` requires `interaction`, including a risk description for medium or high risk. Do not fill missing required fields by guessing.
21
21
  4. After real cloud acceptance, immediately display **云端任务 <taskNumber> · 已进入**. Preserve `taskNumber` verbatim: it is the homepage sandbox tab number. Keep its UUID `taskId` separately. Run the returned `cloud-watch`, show each real public event and its cursor, and continue the original accepted flow from status. A command log alone is insufficient. No accepted cloud task means no cloud ID or model-execution claim.
22
22
  5. For an accepted `flowId`, execute the returned `nextStep` with its required payload. Discover the schema before constructing input. An empty payload is valid only when that operation's schema allows it. Reconnect and refresh only the original flow. A changed snapshot is a binding failure to investigate; do not invent a fresh successful baseline, rebuild a flow to evade it, or erase the old failure. If a fresh scope proof returns `ENTROROVE_SCOPE_PLANNING_REASSESSMENT_REQUIRED` before plan freeze, follow its explicit `workflow-scope-reassess` recovery and retry the original complete request; a local reassessment or snapshot refresh alone does not renew the accepted gate. If the terminal flow truly owns a stale task slot, use the explicit `flow-abandon` lifecycle operation described below before routing the original task again.
23
23
  6. All governed mutations use the original authority: `local guarded-write`, `local guarded-delete`, `local guarded-move`, or the original frozen approved runner plan. Native patches, scripts, terminal writes, database mutations and unknown MCP operations must not substitute for the controlled entry. User-authorized task messages use the host's message tool and remain subject to host approval; sending a report does not change the flow or grant execution authority. **A failed gate or unknown outcome must never be replaced with another executor or reported as local success.** Query the original request read-only, repair the cause, and obtain the original flow's valid authority before proceeding.
24
- For a restricted member scope, follow a returned `localAction` before submitting execution evidence. `mutation-connect` obtains only the original task's limited editing pass; it keeps unresolved loader files and project configuration read only. After actual writes, refresh the original scope and run `mutation-settle`. Use the returned `previousPassId` for further edits. With sandbox disabled, `workflow-observe-step` runs the original approved command and preserves its real local result without a validator private key; independent verification remains pending. See [IDE-EXECUTION.md](IDE-EXECUTION.md#restricted-member-editing).
24
+ For a member scope, follow a returned `localAction` before submitting execution evidence. `mutation-connect` obtains only the original task's limited editing pass; it keeps unresolved loader files and project configuration read only. After actual writes, refresh the original scope and run `mutation-settle`. Use the returned `previousPassId` for further edits. In a signed member-local plan, `workflow-observe-step` runs the original approved command and tests without a validator private key. A server-signed `localAcceptance` advances local dependencies; a failed test does not. Preserve the raw observation and its absent independent receipt. See [IDE-EXECUTION.md](IDE-EXECUTION.md#local-ide-acceptance).
25
25
  7. Read [REUSE-DISCIPLINE.md](REUSE-DISCIPLINE.md) when modifying an existing system. The comparison baseline is the existing implementation in that same repository. It never means another project, another account, or copying external systems.
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
- 9. Before claiming verified completion, submit actual independent execution evidence, run the server's verification and verdict, and display the confirmed evaluation acknowledgement. Preserve `receiptId`, `requestId`, execution generation and evaluation digest. “已回传” requires `acknowledgement.stored: true`. Missing or failed feedback remains visible. Completed verified tasks automatically release superseded Entrorove snapshot copies, preserving origin and final rollback images; active, waiting and unknown tasks retain recovery data.
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
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.
30
30
 
@@ -23,10 +23,14 @@ export function verifyAutomaticFeedback(feedback, requestId, requestHash) {
23
23
  throw new Error(`Invalid automatic feedback receipt; query feedback ${requestId} without repeating the invocation`);
24
24
  }
25
25
  const { digest: checksum,...core } = evaluation;
26
- if (digest(core) !== checksum || !['succeeded','reported','blocked','failed','incomplete'].includes(evaluation.status)
26
+ const local = evaluation.status === 'local-accepted';
27
+ if (digest(core) !== checksum || !['succeeded','reported','blocked','failed','incomplete','local-accepted'].includes(evaluation.status)
28
+ || local && (evaluation.verificationMode !== 'member-local' || evaluation.independentValidation !== 'not-performed'
29
+ || evaluation.disposition !== 'good' || evaluation.validation !== 'passed')
30
+ || !local && (evaluation.verificationMode !== undefined || evaluation.independentValidation !== undefined)
27
31
  || evaluation.findingCount < evaluation.p0Count + evaluation.p1Count
28
32
  || evaluation.disposition === 'pending' && (evaluation.score !== null || evaluation.validation !== 'incomplete')
29
- || evaluation.disposition === 'good' && (evaluation.status !== 'succeeded' || evaluation.validation !== 'passed' || evaluation.p0Count || evaluation.p1Count)
33
+ || evaluation.disposition === 'good' && (!['succeeded','local-accepted'].includes(evaluation.status) || evaluation.validation !== 'passed' || evaluation.p0Count || evaluation.p1Count)
30
34
  || evaluation.disposition === 'bad' && evaluation.validation !== 'failed'
31
35
  || evaluation.disposition !== 'pending' && (!Number.isInteger(evaluation.score) || evaluation.score < 0 || evaluation.score > 100)) {
32
36
  throw new Error('Automatic feedback digest or validation state is inconsistent');
package/connected-cli.mjs CHANGED
@@ -14,7 +14,8 @@ export async function runConnectedCommand(command, root, input, dependencies) {
14
14
  if (!isConnectedCommand(command)) throw new Error('Unsupported connected command');
15
15
  const result = await commands[command](root, input, dependencies);
16
16
  process.stdout.write(`${JSON.stringify(result)}\n`);
17
- const expected = command === 'mutation-settle' ? ['reported', 'succeeded'] : ['succeeded'];
17
+ const expected = command === 'mutation-settle' ? ['reported', 'succeeded']
18
+ : command === 'workflow-observe-step' ? ['succeeded', 'local-accepted'] : ['succeeded'];
18
19
  if (!expected.includes(result.status)) process.exitCode = 2;
19
20
  return result;
20
21
  }
package/ide-hook.mjs CHANGED
@@ -10,11 +10,17 @@ import { hostTaskMessageInput, hostTaskMessageTool, prepareIdeCodeMode } from '.
10
10
  import { pathToFileURL } from 'node:url';
11
11
  import path from 'node:path';
12
12
  import { parseIdeResults, consumeIdeResults } from './ide-results.mjs';
13
+ import { verifyIdeLocalAcceptance } from './ide-local-acceptance.mjs';
13
14
  import { registerIdeRequirement, ideQueueContext, unclassifiedIdeRequirements,
14
15
  advanceIdeQueue, checkQueuedStart, unfinishedIdeQueue } from './ide-queue.mjs';
15
16
 
16
17
  const output = (event, extra = {}) => ({ hookSpecificOutput: { hookEventName: event, ...extra } });
17
18
  const deny = reason => output('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
19
+ export const IDE_HOOK_MAX_INPUT_BYTES = 32 * 1024 * 1024;
20
+ const inputErrors = {
21
+ ENTROROVE_IDE_INPUT_TOO_LARGE: 'ENTROROVE_IDE_INPUT_TOO_LARGE:Entrorove hook 事件超过 32 MiB UTF-8 字节上限;本次事件已拒绝。',
22
+ ENTROROVE_IDE_INPUT_INVALID_JSON: 'ENTROROVE_IDE_INPUT_INVALID_JSON:Entrorove hook 事件不是有效的 UTF-8 JSON 对象;本次事件已拒绝。',
23
+ };
18
24
  const publicId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,200}$/u.test(value);
19
25
  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']);
20
26
  const unlockCommand = 'entrorove unlock';
@@ -83,9 +89,9 @@ function lifecycleResult(value, depth = 0) {
83
89
  }
84
90
  return null;
85
91
  }
86
- function collectResult(state, values, local) {
92
+ function collectResult(state, values, local, acceptances = []) {
87
93
  const messages = [];
88
- for (const result of values) {
94
+ for (const [index, result] of values.entries()) {
89
95
  const value = result.output ?? result;
90
96
  const lifecycleValue = lifecycleResult(result) ?? value;
91
97
  const lifecycle = lifecycleValue.operation === 'flow-cancel' || lifecycleValue.operation === 'flow-abandon';
@@ -105,7 +111,10 @@ function collectResult(state, values, local) {
105
111
  state.taskId = value.taskId; state.taskNumber = value.taskNumber;
106
112
  }
107
113
  const nextStep = value.nextStep ?? value.result?.nextStep ?? value.result?.result?.nextStep;
108
- if (!lifecycle && (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction)) state.phase = 'needs-input';
114
+ if (acceptances[index] === 'workflow') { state.phase = 'ended'; delete state.nextStep; }
115
+ else if (acceptances[index] === 'step') { state.phase = 'continuing'; state.nextStep = { operation: 'task-resume', payload: { flowId: state.flowId } }; }
116
+ else if (acceptances[index] === 'invalid') state.phase = 'blocked';
117
+ else if (!lifecycle && (value.status === 'needs-input' || value.result?.status === 'needs-input' || value.interaction || value.result?.interaction)) state.phase = 'needs-input';
109
118
  else if (isStageTerminal(value.status) || isStageTerminal(value.result?.status)) {
110
119
  state.phase = 'delivered'; state.deliveredStage = stageOf(value.status) ?? stageOf(value.result?.status);
111
120
  }
@@ -116,7 +125,7 @@ function collectResult(state, values, local) {
116
125
  && result.feedback?.acknowledgement?.stored === true) state.phase = 'ended';
117
126
  else if (!lifecycle && local && value.status === 'verified') state.phase = 'continuing';
118
127
  else if (!lifecycle && (value.status === 'started' || value.status === 'succeeded')) state.phase = 'continuing';
119
- if (!lifecycle && nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
128
+ if (!lifecycle && !acceptances[index] && nextStep) state.nextStep = { operation: nextStep.operation, payload: nextStep.payload };
120
129
  const notices = [...(result.ideMessages ?? []), ...(value.ideMessages ?? []),
121
130
  ...(value.schemaVersion === 'entrorove.ide-event/1.0' ? [value.message] : []), ...(value.events ?? []).map(item => item.message)];
122
131
  for (const message of notices) {
@@ -129,6 +138,11 @@ function collectResult(state, values, local) {
129
138
  state.seen = state.seen.slice(-200);
130
139
  return messages;
131
140
  }
141
+ function originalLocalCall(pending, call, input) {
142
+ if (call.args[0] === 'workflow-observe-step') pending.observation = Object.fromEntries(
143
+ ['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].map(field => [field, input?.[field]]));
144
+ if (operationOf(call) === 'continue') pending.continuationOperation = input?.operation;
145
+ }
132
146
  async function beforeTool(event, state, home) {
133
147
  if (state.phase === 'user-exited') return {};
134
148
  const unlocked = await readBreakGlass(home, state.sessionId);
@@ -185,6 +199,7 @@ async function beforeTool(event, state, home) {
185
199
  operation: operationOf(call), observer, managementOnly: observer || isManagementCall(call)
186
200
  && !['start', 'continue', 'cloud-task'].includes(operationOf(call)) || operationOf(call) === 'task-checkpoint',
187
201
  ...(requestId ? { requestId } : {}) };
202
+ originalLocalCall(pending, call, input);
188
203
  if (observer) {
189
204
  state.readCalls ??= {};
190
205
  if (Object.keys(state.readCalls).length >= 32) return denyBlocked(state, event, 'Entrorove:只读恢复尚未返回,请等待原调用。');
@@ -282,8 +297,14 @@ export async function runIdeHook(event, { home } = {}) {
282
297
  }) : values;
283
298
  const recoveredOriginal = pendingRead && state.pendingCall?.requestId
284
299
  && values.some(result => (result.output ?? result).requestId === state.pendingCall.requestId);
285
- const messages = collectResult(state, bound, pending.command === 'local');
286
- observeIdeToolOutcome(state, bound, pending);
300
+ const acceptances = await Promise.all(bound.map(value => verifyIdeLocalAcceptance(value, pending, state, home)));
301
+ const messages = collectResult(state, bound, pending.command === 'local', acceptances);
302
+ // Preserve the full raw O in the completion file/output. Only this control
303
+ // projection can omit its pending status after the signed proof is checked.
304
+ const outcomes = bound.map((value, index) => acceptances[index] === 'step'
305
+ ? { status: 'local-accepted', nextStep: { operation: 'task-resume' } }
306
+ : acceptances[index] === 'invalid' ? { status: 'blocked' } : value);
307
+ observeIdeToolOutcome(state, outcomes, pending);
287
308
  if (pendingRead) delete state.readCalls[event.tool_use_id];
288
309
  if (!pendingRead || recoveredOriginal) delete state.pendingCall;
289
310
  const resumed = state.phase === 'blocked' || repeatedIdeBlock(state) ? '' : await advanceIdeQueue(state,home);
@@ -304,17 +325,29 @@ export async function runIdeHook(event, { home } = {}) {
304
325
  }, home);
305
326
  }
306
327
  let incomingEvent;
328
+ async function readHookEvent() {
329
+ const chunks = []; let bytes = 0;
330
+ for await (const chunk of process.stdin) {
331
+ const buffer = Buffer.from(chunk); bytes += buffer.length;
332
+ if (bytes > IDE_HOOK_MAX_INPUT_BYTES) throw Object.assign(new Error('Hook event byte limit exceeded'), { code: 'ENTROROVE_IDE_INPUT_TOO_LARGE' });
333
+ chunks.push(buffer);
334
+ }
335
+ try {
336
+ const value = JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes)));
337
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
338
+ return value;
339
+ } catch { throw Object.assign(new Error('Hook event JSON is invalid'), { code: 'ENTROROVE_IDE_INPUT_INVALID_JSON' }); }
340
+ }
307
341
  async function main() {
308
- let raw = '';
309
- for await (const chunk of process.stdin) { raw += chunk; if (raw.length > 1_000_000) throw new Error('Host hook input is too large'); }
310
- const event = JSON.parse(raw); incomingEvent = event.hook_event_name;
342
+ const event = await readHookEvent(); incomingEvent = event.hook_event_name;
311
343
  const result = await runIdeHook(event);
312
344
  process.stdout.write(`${JSON.stringify(result)}\n`);
313
345
  }
314
- if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(() => {
346
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(cause => {
315
347
  // A non-zero generic exit may fail open in the host. Always return a supported
316
348
  // explicit denial, without including arguments, prompts, credentials or paths.
317
- const reason = `Entrorove 安全检查失败;本次工具调用已阻止。可复制解锁命令:${unlockCommand}`;
349
+ const reason = Object.hasOwn(inputErrors, cause?.code) ? inputErrors[cause.code]
350
+ : `Entrorove 安全检查失败;本次工具调用已阻止。可复制解锁命令:${unlockCommand}`;
318
351
  const result = incomingEvent === 'Stop' ? { decision: 'block', reason } : incomingEvent === 'PreToolUse' ? deny(reason)
319
352
  : { continue: false, stopReason: reason, systemMessage: reason };
320
353
  process.stdout.write(`${JSON.stringify(result)}\n`);
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-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', '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
@@ -0,0 +1,134 @@
1
+ import { createHash, createPublicKey, verify } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { readIdeJson } from './ide-session.mjs';
6
+
7
+ const hash = value => createHash('sha256').update(value).digest('hex');
8
+ const sha = value => typeof value === 'string' && /^[a-f0-9]{64}$/u.test(value);
9
+ const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
10
+ const exact = (value, fields) => value && typeof value === 'object' && !Array.isArray(value)
11
+ && Object.keys(value).sort().join('\0') === [...fields].sort().join('\0');
12
+ function canonical(value) {
13
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
14
+ if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
15
+ return JSON.stringify(value);
16
+ }
17
+ const digest = value => hash(canonical(value));
18
+ const common = ['schemaVersion', 'status', 'verificationMode', 'independentValidation', 'runnerKeyId',
19
+ 'flowId', 'ownerId', 'taskId', 'taskDigest', 'chainDigest', 'planId', 'planDigest', 'requestId', 'acceptedAt', 'authorityReceipt'];
20
+ const step = ['stepId', 'executionPlanSha256', 'leaseId', 'leaseDigest', 'generation', 'observationId', 'admissionDigest', 'resultDigest'];
21
+ const workflow = ['stepAcceptances', 'claimsIndependentVerification', 'localExecutionAndTestsPassed'];
22
+ function localCore(value) {
23
+ if (value?.localAcceptance || value?.status === 'local-accepted') return value;
24
+ if (value?.result?.localAcceptance || value?.result?.status === 'local-accepted') return value.result;
25
+ return null;
26
+ }
27
+ export function ideLocalAcceptanceMetadata(value) {
28
+ if (!localCore(value) || Buffer.byteLength(JSON.stringify(value)) > 100_000) return null;
29
+ // A complete bounded output is needed to check the acknowledged feedback's
30
+ // original digest. No command output is included in local observation results.
31
+ return structuredClone(value);
32
+ }
33
+ async function authority(home = os.homedir()) {
34
+ for (const directory of [home, path.join(home, '.entrorove')]) {
35
+ const stat = await fs.lstat(directory);
36
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.platform !== 'win32'
37
+ && (stat.uid !== process.getuid() || directory !== home && stat.mode & 0o077)) throw new Error();
38
+ }
39
+ const pin = await readIdeJson(path.join(home, '.entrorove', 'cloud-mutation-authority.json'));
40
+ const testOrigin = process.env.NODE_ENV === 'test' && /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):[0-9]+$/u.test(pin?.apiOrigin ?? '');
41
+ if (!exact(pin, ['schemaVersion', 'apiOrigin', 'authority']) || pin.schemaVersion !== 'entrorove.cloud-mutation-authority-pin/1.0'
42
+ || pin.apiOrigin !== 'https://entrorove.com' && !testOrigin
43
+ || !exact(pin.authority, ['schemaVersion', 'purpose', 'algorithm', 'keyId', 'publicKey'])
44
+ || pin.authority.schemaVersion !== 'entrorove.mutation-authority-key/1.0'
45
+ || pin.authority.purpose !== 'mutation-pass-verification' || pin.authority.algorithm !== 'Ed25519') throw new Error();
46
+ const encoded = pin.authority.publicKey, der = Buffer.from(encoded, 'base64');
47
+ const key = createPublicKey({ key: der, type: 'spki', format: 'der' });
48
+ if (key.asymmetricKeyType !== 'ed25519' || der.toString('base64') !== encoded
49
+ || key.export({ type: 'spki', format: 'der' }).toString('base64') !== encoded || hash(der) !== pin.authority.keyId) throw new Error();
50
+ return { key, keyId: hash(der) };
51
+ }
52
+ async function signedAcceptance(value, kind, home) {
53
+ if (!exact(value, [...common, ...(kind === 'step' ? step : workflow)])
54
+ || value.schemaVersion !== `entrorove.member-local-${kind === 'step' ? 'step' : 'workflow'}-acceptance/1.0`
55
+ || value.status !== 'local-accepted' || value.verificationMode !== 'member-local'
56
+ || value.independentValidation !== 'not-performed' || value.runnerKeyId !== null
57
+ || !['flowId', 'taskId', 'planId'].every(field => id(value[field]))
58
+ || !/^[a-f0-9-]{36}$/u.test(value.ownerId ?? '')
59
+ || !['taskDigest', 'chainDigest', 'planDigest', 'requestId'].every(field => sha(value[field]))
60
+ || typeof value.acceptedAt !== 'string' || !Number.isFinite(Date.parse(value.acceptedAt))
61
+ || new Date(value.acceptedAt).toISOString() !== value.acceptedAt) throw new Error();
62
+ const receipt = value.authorityReceipt, trusted = await authority(home);
63
+ if (!exact(receipt, ['schemaVersion', 'keyId', 'payloadDigest', 'signature'])) throw new Error();
64
+ const { authorityReceipt: _, ...payload } = value, bytes = Buffer.from(canonical(payload));
65
+ const signature = Buffer.from(receipt.signature ?? '', 'base64url');
66
+ if (receipt.schemaVersion !== 'entrorove.workflow-authority-receipt/1.0' || receipt.keyId !== trusted.keyId
67
+ || receipt.payloadDigest !== hash(bytes) || signature.length !== 64 || signature.toString('base64url') !== receipt.signature
68
+ || !verify(null, bytes, trusted.key, signature)) throw new Error();
69
+ }
70
+ function successfulObservation(proof, result) {
71
+ const process = result?.process, checks = ['sourceManifestUnchanged', 'protectedFilesUnchanged', 'repositoryUnchanged'];
72
+ return result?.schemaVersion === 'entrorove.local-observation-result/1.0' && result.status === 'incomplete'
73
+ && result.independentValidation === 'pending' && result.runner === 'local' && result.receipt === null
74
+ && result.executionIsolation === 'host-permissions' && result.externalEffects === 'unverified'
75
+ && result.observationId === proof.observationId && result.admissionDigest === proof.admissionDigest
76
+ && digest(result) === proof.resultDigest && sha(result.sourceManifestDigest)
77
+ && exact(result.checks, checks) && checks.every(field => result.checks[field] === true)
78
+ && sha(result.beforeRepositoryDigest) && result.beforeRepositoryDigest === result.afterRepositoryDigest
79
+ && process?.started === true && process.status === 'succeeded' && process.exitCode === 0 && process.signal === null
80
+ && process.timedOut === false && process.outputLimitExceeded === false;
81
+ }
82
+ function acknowledgedLocalFeedback(result, output, requestId) {
83
+ const feedback = result.feedback, evaluation = feedback?.evaluation, ack = feedback?.acknowledgement;
84
+ if (!evaluation) return false;
85
+ const { digest: checksum, ...core } = evaluation;
86
+ return feedback.schemaVersion === 'entrorove.automatic-feedback/1.0' && feedback.stored === true
87
+ && feedback.requestId === requestId && feedback.requestHash === requestId && feedback.resultDigest === digest(output)
88
+ && evaluation.requestId === requestId && evaluation.schemaVersion === 'entrorove.automatic-evaluation/1.0'
89
+ && evaluation.status === 'local-accepted' && evaluation.verificationMode === 'member-local'
90
+ && evaluation.independentValidation === 'not-performed' && evaluation.disposition === 'good'
91
+ && evaluation.validation === 'passed' && evaluation.p0Count === 0 && evaluation.p1Count === 0
92
+ && checksum === digest(core) && ack?.schemaVersion === 'entrorove.feedback-acknowledgement/1.0'
93
+ && ack.stored === true && ack.receiptId === feedback.receiptId && ack.requestId === requestId
94
+ && ack.generation === (feedback.generation ?? 1);
95
+ }
96
+ function workflowProof(proof) {
97
+ return proof.claimsIndependentVerification === false && proof.localExecutionAndTestsPassed === true
98
+ && Array.isArray(proof.stepAcceptances) && proof.stepAcceptances.length > 0 && proof.stepAcceptances.length <= 2_000
99
+ && new Set(proof.stepAcceptances.map(value => value.stepId)).size === proof.stepAcceptances.length
100
+ && proof.stepAcceptances.every(value => exact(value, ['stepId', 'acceptanceDigest', 'observationId', 'resultDigest'])
101
+ && id(value.stepId) && id(value.observationId) && sha(value.acceptanceDigest) && sha(value.resultDigest));
102
+ }
103
+ export async function verifyIdeLocalAcceptance(result, pending, state, home) {
104
+ const output = result?.output ?? result, source = output?.localAcceptanceOutput ?? output, core = localCore(source);
105
+ if (!core) return null;
106
+ try {
107
+ const proof = core.localAcceptance, kind = proof?.schemaVersion === 'entrorove.member-local-step-acceptance/1.0' ? 'step' : 'workflow';
108
+ await signedAcceptance(proof, kind, home);
109
+ if (core.status !== 'local-accepted' || core.independentValidation !== 'not-performed'
110
+ || !['local-accepted', 'succeeded'].includes(output.status) || source.flowId && source.flowId !== proof.flowId
111
+ || proof.flowId !== state.flowId || state.taskId && proof.taskId !== state.taskId) throw new Error();
112
+ if (kind === 'step') {
113
+ if (pending.command !== 'workflow-observe-step' || !pending.observation
114
+ || core.grantsExecutionAuthority !== false || core.grantsValidatorAuthority !== false || core.stored !== true
115
+ || core.localObservationStatus !== 'local-accepted' || core.receipt !== null || core.resultDigest !== proof.resultDigest
116
+ || ['taskId', 'flowId', 'stepId'].some(field => core[field] !== proof[field])
117
+ || ['taskId', 'flowId', 'stepId', 'leaseId', 'generation'].some(field => proof[field] !== pending.observation[field])
118
+ || !['stepId', 'leaseId', 'observationId'].every(field => id(proof[field]))
119
+ || !['executionPlanSha256', 'leaseDigest', 'admissionDigest', 'resultDigest'].every(field => sha(proof[field]))
120
+ || !Number.isSafeInteger(proof.generation) || proof.generation < 1 || !successfulObservation(proof, core.result)
121
+ || proof.requestId !== hash('member-observation-report' + canonical({ observationId: proof.observationId,
122
+ admissionDigest: proof.admissionDigest, result: core.result }))) throw new Error();
123
+ } else if (core.verificationMode !== 'member-local' || pending.operation !== 'continue' || pending.continuationOperation !== 'workflow-verdict'
124
+ || pending.requestId !== proof.requestId || source.requestId !== proof.requestId || !workflowProof(proof)
125
+ || !acknowledgedLocalFeedback(result, source, proof.requestId)) throw new Error();
126
+ return kind;
127
+ } catch { return 'invalid'; }
128
+ }
129
+ export async function verifiedLocalWorkflowCompletion(call, result, home) {
130
+ const output = result?.output;
131
+ if (call.operation !== 'continue' || call.input?.operation !== 'workflow-verdict' || !id(call.input.flowId)) return false;
132
+ return await verifyIdeLocalAcceptance(result, { operation: 'continue', continuationOperation: 'workflow-verdict', requestId: call.requestId },
133
+ { flowId: call.input.flowId }, home) === 'workflow' && output?.flowId === call.input.flowId;
134
+ }
package/ide-results.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { promises as fs } from 'node:fs';
3
3
  import { ideDirectory, ideDigest, ideResultContext, readIdeJson, writeIdeJson } from './ide-session.mjs';
4
+ import { ideLocalAcceptanceMetadata } from './ide-local-acceptance.mjs';
4
5
 
5
6
  export function parseIdeResults(response) {
6
7
  const raw = typeof response === 'string' ? response : response?.output ?? response?.stdout ?? response?.aggregated_output
@@ -14,6 +15,8 @@ export function parseIdeResults(response) {
14
15
  function resultMetadata(value, depth = 0) {
15
16
  if (!value || typeof value !== 'object' || depth > 3) return undefined;
16
17
  const metadata = {};
18
+ const localOutput = ideLocalAcceptanceMetadata(value);
19
+ if (localOutput) metadata.localAcceptanceOutput = localOutput;
17
20
  for (const key of ['schemaVersion', 'requestId', 'flowId', 'status', 'operation', 'termination', 'flowStatus', 'taskId', 'taskNumber', 'taskStatus']) {
18
21
  if (typeof value[key] === 'string' && value[key].length <= 200) metadata[key] = value[key];
19
22
  }
@@ -22,7 +25,8 @@ function resultMetadata(value, depth = 0) {
22
25
  for (const key of ['nextStep', 'nextAction']) if (typeof value[key]?.operation === 'string') metadata[key] = { operation: value[key].operation };
23
26
  if (value.interaction) metadata.interaction = true;
24
27
  if (Array.isArray(value.reports)) metadata.reports = value.reports.map(report => ({ verdict: report.verdict }));
25
- if (value.feedback?.acknowledgement?.stored === true) metadata.feedback = { receiptId: value.feedback.receiptId, acknowledgement: { stored: true } };
28
+ if (value.feedback?.acknowledgement?.stored === true) metadata.feedback = ideLocalAcceptanceMetadata(value.output)
29
+ ? structuredClone(value.feedback) : { receiptId: value.feedback.receiptId, acknowledgement: { stored: true } };
26
30
  return metadata;
27
31
  }
28
32
  const resultFile = (directory, sessionId, toolUseId) => path.join(directory, `result-${ideDigest({ sessionId, toolUseId })}.json`);
@@ -17,6 +17,7 @@ const PASS_FIELDS = ['schemaVersion', 'passId', 'challengeId', 'challengeDigest'
17
17
  'snapshotManifestDigest', 'attestationDigest', 'coordinationRequired'];
18
18
  const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
19
19
  const SHA = /^[0-9a-f]{64}$/;
20
+ const localRoleFields = ['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'];
20
21
  function exact(value, fields) {
21
22
  if (!value || typeof value !== 'object' || Array.isArray(value)
22
23
  || Object.keys(value).sort().join('\0') !== [...fields].sort().join('\0')) {
@@ -39,13 +40,21 @@ function pinnedAuthority() {
39
40
  } catch { mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Pinned cloud mutation authority is unavailable or invalid'); }
40
41
  }
41
42
  function identity(payload) {
43
+ const local = payload.verificationMode === 'member-local';
44
+ if (local ? payload.runnerKeyId !== null || !payload.writeDelegation
45
+ || !SHA.test(payload.memberScopeKeyId ?? '') || !SHA.test(payload.memberScopeBindingDigest ?? '')
46
+ || payload.memberScopeKeyId !== payload.writeDelegation.scopeKeyId || payload.memberScopeBindingDigest !== payload.scopeBindingDigest
47
+ : (payload.verificationMode !== undefined && payload.verificationMode !== 'independent') || !SHA.test(payload.runnerKeyId ?? '')
48
+ || Object.hasOwn(payload, 'memberScopeKeyId') || Object.hasOwn(payload, 'memberScopeBindingDigest')) {
49
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation verification roles must preserve their original independent or member-local authority');
50
+ }
42
51
  if (Object.hasOwn(payload, 'previousPassId') && (!ID.test(payload.previousPassId) || payload.previousPassId === payload.passId)) {
43
52
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation continuation must retain a distinct original settled predecessor');
44
53
  }
45
54
  const ids = ['flowId', 'taskId', 'acceptanceReceiptId', 'stepId', 'leaseId', 'planId', 'executionAuthorityId',
46
55
  'executionAttemptNonce', 'runnerId', 'repositoryId', 'snapshotId', 'nonce', 'challengeId'];
47
56
  const digests = ['taskDigest', 'chainDigest', 'acceptanceDigest', 'routeDigest', 'receiverDigest', 'leaseDigest',
48
- 'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', 'runnerKeyId', 'scopeBindingDigest',
57
+ 'planDigest', 'executionPlanSha256', 'executionAuthorityDigest', ...(local ? [] : ['runnerKeyId']), 'scopeBindingDigest',
49
58
  'scopeArtifactDigest', 'scopeContractDigest'];
50
59
  const delegated = Object.hasOwn(payload, 'writeDelegation');
51
60
  const execution = new Set(['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce']);
@@ -83,6 +92,9 @@ function authority(value, fields, schema, options) {
83
92
  || receipt.payloadDigest !== sha256(bytes) || !verify(null, bytes, key.key, signature)) {
84
93
  mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'Mutation authority requires a valid pinned cloud signature');
85
94
  }
95
+ if (payload.runnerKeyId !== null && payload.runnerKeyId === receipt.keyId) {
96
+ mutationFail14('ENTROROVE_MUTATION_AUTHORITY_INVALID', 'The workflow authority key cannot become an independent Validator runner');
97
+ }
86
98
  identity(payload); mutationLifetime14(payload, options); return value;
87
99
  }
88
100
  export function mutationChallenge14(value, options) {
@@ -91,7 +103,7 @@ export function mutationChallenge14(value, options) {
91
103
  function authorityFields(value, fields) {
92
104
  const original = Object.hasOwn(value ?? {}, 'writeDelegation') ? [...fields.filter(field =>
93
105
  !['executionAuthorityId', 'executionAuthorityDigest', 'executionAttemptNonce'].includes(field)), 'writeDelegation'] : fields;
94
- return [...original, ...['previousPassId'].filter(field => Object.hasOwn(value ?? {}, field))];
106
+ return [...original, ...['previousPassId', ...localRoleFields].filter(field => Object.hasOwn(value ?? {}, field))];
95
107
  }
96
108
  export function mutationPass14(value, options) {
97
109
  const fields = [...authorityFields(value, PASS_FIELDS), ...['disciplinePolicy', 'parallelPaths'].filter(key => Object.hasOwn(value ?? {}, key))];
@@ -8,7 +8,9 @@ import { readPreservedMutationBinding, loadPreservedMutationSigner } from './loc
8
8
  import { preservedMutationDelegation, preservedMutationFail, preservedMutationPublicKey } from './local-preserved-mutation-wire.mjs';
9
9
 
10
10
  const identity = authority => Object.fromEntries(['flowId', 'taskId', 'ownerId', 'taskDigest', 'chainDigest',
11
- 'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest'].map(field => [field, authority[field]]));
11
+ 'repositoryDigest', 'runnerKeyId', 'runnerId', 'repositoryId', 'receiverDigest',
12
+ ...['verificationMode', 'memberScopeKeyId', 'memberScopeBindingDigest'].filter(field => Object.hasOwn(authority, field))]
13
+ .map(field => [field, authority[field]]));
12
14
  async function rejectGenericHistory(location) {
13
15
  const anchor = process.env.ENTROROVE_TASK_HANDOFF_STATE_DIRECTORY;
14
16
  if (!anchor) return;
@@ -30,9 +30,16 @@ export function preservedMutationDelegation(authority) {
30
30
  exact(value, ['schemaVersion', 'purpose', 'scopeKeyId', 'mutationKeyId', 'mutationPublicKey',
31
31
  'gateEvidenceDigest', 'importGraphDigest', 'protectedFiles', 'targetFiles', 'targetImports']);
32
32
  const publicKey = preservedMutationPublicKey(value.mutationPublicKey);
33
+ const local = authority.verificationMode === 'member-local';
34
+ const roleKeys = local ? [value.scopeKeyId, value.mutationKeyId] : [authority.runnerKeyId, value.scopeKeyId, value.mutationKeyId];
35
+ if (local && (authority.runnerKeyId !== null || authority.memberScopeKeyId !== value.scopeKeyId
36
+ || authority.memberScopeBindingDigest !== authority.scopeBindingDigest)
37
+ || !local && authority.runnerKeyId === authority.authorityReceipt?.keyId) {
38
+ preservedMutationFail('Preserved mutation cannot promote scope, mutation or workflow signing keys to independent validation');
39
+ }
33
40
  if (value.schemaVersion !== 'entrorove.preserved-write-delegation/1.0' || value.purpose !== 'preserved-existing-modify'
34
41
  || ['scopeKeyId', 'mutationKeyId', 'gateEvidenceDigest', 'importGraphDigest'].some(field => !/^[0-9a-f]{64}$/.test(value[field]))
35
- || publicKey.keyId !== value.mutationKeyId || new Set([authority.runnerKeyId, value.scopeKeyId, value.mutationKeyId]).size !== 3
42
+ || publicKey.keyId !== value.mutationKeyId || new Set(roleKeys).size !== roleKeys.length
36
43
  || authority.epoch !== 0 || authority.newFilePaths.length !== 0) preservedMutationFail('Preserved mutation role or original epoch is invalid');
37
44
  files(value.protectedFiles); files(value.targetFiles);
38
45
  if (canonical(value.targetFiles.map(file => file.path)) !== canonical(authority.paths)
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.skill-manifest/1.0",
3
3
  "name": "entrorove",
4
- "version": "0.1.56",
4
+ "version": "0.1.58",
5
5
  "endpoint": "https://entrorove.com",
6
6
  "requestSchemaVersion": "entrorove.skill.request/2.0",
7
7
  "supportedRequestSchemaVersions": [
@@ -111,7 +111,8 @@ function passBinding(pass, challenge, attestation, options) {
111
111
  mutationPass14(pass, options);
112
112
  if (pass.challengeId !== challenge.challengeId || pass.challengeDigest !== sha256(canonical(challenge))
113
113
  || pass.attestationDigest !== sha256(canonical(attestation)) || pass.snapshotDigest !== attestation.manifest.snapshotDigest
114
- || [...fields, 'previousPassId'].some(field => canonical(pass[field]) !== canonical(challenge[field]))
114
+ || [...fields, 'previousPassId', 'verificationMode', 'runnerKeyId', 'memberScopeKeyId', 'memberScopeBindingDigest', 'scopeBindingDigest']
115
+ .some(field => canonical(pass[field]) !== canonical(challenge[field]))
115
116
  || canonical(pass.writeDelegation) !== canonical(challenge.writeDelegation)) fail('Mutation pass differs from its original signed challenge or actual M snapshot');
116
117
  }
117
118
  async function snapshot(input, challenge, state, location) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrorove/skill",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "entrorove": "cli.mjs"
@@ -71,6 +71,7 @@
71
71
  "workflow-local-client.mjs",
72
72
  "workflow-local-request.mjs",
73
73
  "workflow-local-contracts.mjs",
74
+ "workflow-local-acceptance.mjs",
74
75
  "local-budget-renewal.mjs",
75
76
  "local-probe.mjs",
76
77
  "local-flow-lifecycle.mjs",
@@ -219,6 +220,7 @@
219
220
  "ide-session.mjs",
220
221
  "break-glass.mjs",
221
222
  "ide-results.mjs",
223
+ "ide-local-acceptance.mjs",
222
224
  "ide-queue.mjs",
223
225
  "local-discipline-actions.mjs",
224
226
  "local-discipline-analysis.mjs",
@@ -1,32 +1,34 @@
1
1
  {
2
2
  "schemaVersion": "entrorove.release-state/1.0",
3
- "version": "0.1.56",
4
- "digest": "217c46f009d2c8513e5697b663e07e8fabe3b647728ce7c07bf20c49037c991e",
3
+ "version": "0.1.58",
4
+ "digest": "503f521d785c3da28cf88eb604daa30ed9c28ab2e847cd8f4472773600fd4b3b",
5
5
  "files": {
6
- "connected-cli.mjs": "260c0fc99586c24bd4d0351a14e0744849a612ebe6f1bd7951e4fce200daf920",
6
+ "connected-cli.mjs": "d5fc2542ae0aa36bf45b0f3d493fe1e7479a4a25c50c8f3730f8b0c4007a7f8f",
7
7
  "local-approved-process.mjs": "442856df788e63f38757439ba0657d1c689aaf797631a955fae3dd639e844e6d",
8
8
  "local-approved-supervisor.mjs": "87142a4c13d27c8265cc1fd7fc9088988da9bbb02134164e1a7c5417da926ac7",
9
9
  "local-mutation-consumption.mjs": "cd8650b9be894639ceb35af88d23c8002b4acff27afad2e64174eb21a9424cfb",
10
- "workflow-observation-client.mjs": "5e67bc32a8625de9523498f694655c378ae34af59c4aa2f0e07efcd72347dbc0",
11
- "workflow-observation-connection.mjs": "daf5f707dd93397e3581dc761131ef380026110b07f58fa9a2c9e738e68fa492",
12
- "workflow-observation-remote.mjs": "1e5a8b9c2b9b095242ad28a1d5ebce5944efdae74383fb20d6676b93b240c5a7",
10
+ "workflow-local-acceptance.mjs": "743ae19c1edff473af2596c7356ddff3892ff9acfb3bcaa660b662eacba463a8",
11
+ "workflow-observation-client.mjs": "4ad34e5a467954ad1943a0f003e07dd6c4fb919d197ae9175a8b0c9b6e2aff32",
12
+ "workflow-observation-connection.mjs": "00e887c1430b13fc49c3f51ae814edb7a05c6d61ed7091262ae91a0706fad084",
13
+ "workflow-observation-remote.mjs": "26f7d741d2e00e4cb05240d8999378b6db3cb1c52eaa586ac20f9448fe296e4b",
13
14
  "workflow-observation-runner.mjs": "3eb8280ec7abffcb30398e3f4e5f7d0ec3df4385fa720f188ed83757b87cd977",
14
15
  "workflow-observation-state.mjs": "a525046df9b3de75b10d2bf35c2b6d7719f12a647e4aee1f1e85dbdbfb167321",
15
- "workflow-observation-wire.mjs": "9bff48377d240c2fd33ee82de6adcd97376b239d32d2552cfa1ce4f7e5e3562e",
16
+ "workflow-observation-wire.mjs": "90047ca4fe028e4931406ff26d4fe871bcafb28a59c456795aae4f5c4b739e43",
16
17
  "local-preserved-mutation-protection.mjs": "95041d4f75253a153c7fd85573ba0457513ffe47a6a6bd00c4f59379b81ce580",
17
18
  "local-preserved-mutation-signer.mjs": "2b0771cbdd18ba00267aa441b7892cd04feab9f8543ae5e2703430b1e580747f",
18
- "local-preserved-mutation-state.mjs": "1ff7939d668e39ec641a7cf46a4ce3014c63f3898207cc874f21170376479671",
19
- "local-preserved-mutation-wire.mjs": "d302bd4b115338c0d1bf69718ce2596dbceb595431ff3f57b998ea67d94cb34c",
19
+ "local-preserved-mutation-state.mjs": "337a172b2d0a03b0d5a89b8483afdd9a043a4999fe38f87679633c98c450126b",
20
+ "local-preserved-mutation-wire.mjs": "2f2f623e768d49af59dbb9bb8245627cb7e72184e5a5d3239d91f395152cf9bf",
20
21
  "local-delegated-modify-analysis.mjs": "f87148ddb023b79e241ca97db582991da8d0e1a51b26b1d9bc2799a76efd7297",
21
- "mutation-client.mjs": "8d27b267dc660510aa1406c44d4005e44b972a1260f90657de4fa0e75b7b6c3e",
22
+ "mutation-client.mjs": "4d5fadb0277ce8cc1eaf96f613ef496201910e64d21355627ec68d88c01b5eb9",
22
23
  "cloud-mutation-authority.mjs": "c7226e882a4f3be4d2f55728f515d5a815c0aa1f21aa5e4689314e6e6a8c66a2",
23
24
  "ide-code-mode.mjs": "f8ba4de33632ab4c6df52dab52f392414b4632c7e94b218b8065677970c075c7",
24
25
  "ide-command.mjs": "3db45dd1d12db670e594b6ced35f2d7778cb01091695b1e240179b1ea34f25ee",
25
- "ide-hook.mjs": "68ca1f5805a49b78182f2508bdd5fac390080e9b2c2ba3645a9aaea69f9d4d0f",
26
- "ide-install.mjs": "10247efadcfeef5da001022d12080bd0f789e67fdf7862bcc65db73dda3bbe57",
26
+ "ide-hook.mjs": "00d67d0a49e2d13d718a376090804804af8f20973f9b552c13c0d53daa375d13",
27
+ "ide-install.mjs": "6322f76d8c0f5c864d8c61f4ee90ae029ccf4bbf1e31fbca7590b78b6c7eaa78",
27
28
  "ide-session.mjs": "6df04bca3231954e8b83f6b1b535befff16b6c62c51ca65ae3aa2559180ad7c2",
28
29
  "break-glass.mjs": "6ec9f97c5d68bb33121b2374c06b9d4c8eb3419b52d00849fec7124bc63e4582",
29
- "ide-results.mjs": "7ed5caf8e77cf42ae6d62cc1ed42b1639bca6069d809b90a399bec5192fb903e",
30
+ "ide-results.mjs": "baa0618845ced901334644479bf4e0d298bad3f955bc18846752de6c283a5573",
31
+ "ide-local-acceptance.mjs": "238eec6d7b6093fb8ad022da0ed3383a48ba2ffcf693e3ce8a241cf9624088d8",
30
32
  "ide-queue.mjs": "806e716c775fd0079417027b135977c8516f47dddb0bc5ca19e32a355dab0e9e",
31
33
  "local-discipline-actions.mjs": "418c220b8c8ef59cb8092b6eb680bb75a93b9fad1a09412b1efa98a78afff11b",
32
34
  "local-discipline-analysis.mjs": "e887abdd1c6d6ba59ef7323e9035528f0491e5458ca8159985ec26e1ca67a4af",
@@ -46,11 +48,11 @@
46
48
  "validator-runtime-build.mjs": "da20bd19eb088d79d62e33969b4c075dc2199761b395e40060227f8f86b9519a",
47
49
  "runtime/validator-runtime-v7.0.44.mjs": "a3bf9aa24146e48ad5702dd498de9bd1ff270fc4263026abf0bf2a4ec20f5321",
48
50
  "runtime/validator-runtime-v7.0.44.d.mts": "7797edbfe2f7de24da370204dce4dfd22822a1bd80df93514fff900b98afe36f",
49
- "snapshot-retention.mjs": "4f8919fe8ec49fb4c23e080a8ad2fa3a9282bca3fffe7cf55c64e89ef316697e",
51
+ "snapshot-retention.mjs": "ca46aeb36b1a5a24fe7e967031a71b9a3d33f40cb715d344402cbe0dc9c425cb",
50
52
  "snapshot-cleanup-journal.mjs": "76a9684cc95b4eb076e1298d78a9dff37948b00d9607b8f546c599072d3ed331",
51
- "IDE-EXECUTION.md": "7e312d8069b0f03deba896b26abfa6beab74ba08835aa58c47633f5c8eeb7d35",
53
+ "IDE-EXECUTION.md": "7d0aa5d1e3dc8dfd3bd0598aa7dc32c50ecd80197cf6958bf836c0b434a9ae5e",
52
54
  "REUSE-DISCIPLINE.md": "5b214cf313e7f715f52d54e28093a2d038f0f196ca64b15d1c49587b9d8eecf8",
53
- "reuse-discipline-release.mjs": "2935898d1aa807fb982a78d7ab5cb1abe05e3c8fc945dc0909da8098872aa1ac",
55
+ "reuse-discipline-release.mjs": "7f55ae10ec3396ed3bc5430a0c64f94c33c2dfbc57b32084caab92fd5fd1586f",
54
56
  "local-final-gate16.mjs": "083aec470670e6cdb7ad2ab27b7a51b53a9ec3a8b4121cb9cbfa17d9940892c2",
55
57
  "local-final-gate16-checks.mjs": "1dd30e9666d458d8b12ca6e4975f6f0ac8450c3ea9cbecc7401ae037f66b589a",
56
58
  "local-final-gate16-worker.mjs": "344d64b6c1c5552b96960e0d8514a876a3dbfa9762ab9eb749038e8351727121",
@@ -96,13 +98,13 @@
96
98
  "install-cleanup.mjs": "8a8e1a7dfe37427f86cd4d6e317890d0829894a9949d4216674a716b36edbc54",
97
99
  "review-client.mjs": "3491eef68ed533174b31db3c53febb6519b8f088f71a9964644714a9a4c03c77",
98
100
  "version.mjs": "1f392147d72c40af4b03b307ea607075fb815af5174fa07e254b95fe12dbf3e7",
99
- "SKILL.md": "684438bcdfefef590e51e4ce02be0a244a26d142a24599258bf3ae24c86bbfa1",
100
- "CAPABILITY-MAP.md": "9058ee785768b470f26641032dfdaca638e08473d3508ad36deb63f375b84afe",
101
+ "SKILL.md": "96f83041a2f9933876e6a94f487e472e95d029251375449336ae4fc535801ef9",
102
+ "CAPABILITY-MAP.md": "0e85f26ebf2a5be5e6e5d2af66f910db03c69cb268edad1b2541dd3834991f90",
101
103
  "AGREEMENT.md": "f93a68e92c457200dd024610e65f74e478b8f60860f951da818b70561f1b9bf8",
102
104
  "DELIVERY-BOUNDARIES.md": "9b9db50ed1bcce9e77ff6527b6bb7eaf12f3b913162ea9cb7fe90db06b393a4f",
103
- "package.json": "19e30a6adde349d335e9a0b4559799a5af6a1b7df7094344dafd411f6ed7f22f",
105
+ "package.json": "3fbb42236d00d8c5a92c1cc4c75de4cea0581b8ee22721fe57aa760513744787",
104
106
  "manifest.json": "ab06e307795bc9da5add926752b6c4dbdaf18625090261a035aa356a679c504f",
105
- "release.mjs": "8b0dc36c47aec72cf9952c9ac1ce5fe27da5ae6829f50cd2339fa101b2eda93c",
107
+ "release.mjs": "a0693132999f5be6030761c3992071d7416a75471f6b73bdd720d52f55ea3c3f",
106
108
  "local-cli.mjs": "41e2a737c8587cb5ce469a7661f6d14b5ea4cd073f8286822fc4dc96b67f56a1",
107
109
  "local-state-lock.mjs": "1dbf64264642a0851faf043e07ee9f66d2ad0508e9f799b0626246e42fae893e",
108
110
  "local-execution.mjs": "185f34e917440a360b3c4bafd3af245243807d964ae3820b3cffaaa2e6348cc6",
@@ -183,7 +185,7 @@
183
185
  "cloud-watch-client.mjs": "5b93935c19a104d72afed07d4a56746c9617707e075884b93d22050c95cee0c1",
184
186
  "cloud-event-stream.mjs": "e0eec9fc3e0613094523929634f25030df5ca5c5b5fe2c1e1a4586ab10574808",
185
187
  "ide-presentation.mjs": "3bac21bfc824fcdad63aafb0d7c3326cd6456923f4224106dd5c38682060f315",
186
- "automatic-feedback-client.mjs": "75e92f50d6fbfa4f482af36e16c9ce5dd323a02e99eb9b434dcfa3faa846a532",
188
+ "automatic-feedback-client.mjs": "532312d29e66fa211ea3fe879d9add1dd48841ee5ce82800b4b06252a160a255",
187
189
  "task-entry-client.mjs": "869f234402c4149eadf1ec138705fc140e2fa16b2a3ea5dc2b2f51dfb189d7de",
188
190
  "task-identity.mjs": "e0e6cf1f48bfa5ee3c5cb86a3c21adfce6d66b4220f4a164ca099263c7a09fb4",
189
191
  "task-identity.d.mts": "55472e1ffc81b9d5e5c5bd56c0bf1d18ecde03ee5170a58a15ce9caf222d8761",
@@ -201,8 +203,8 @@
201
203
  "local-task-handoff-read-authority.mjs": "7d2991cfac4562039232566d25441aa98106ce27bc9b7bcf6df4ff28d738e167",
202
204
  "local-task-handoff-write-archive.mjs": "3668f39d8ab5664ed717994cf1b4bcc7ea23e146a0e06291f851d30337faeb77",
203
205
  "local-task-handoff-write-receipts.mjs": "e1321127bd6fcad7840e185639f81ae68a8722a0ee4264a7b504a02b27887647",
204
- "workflow-local-runner.mjs": "c74ccd425fd7d4882388338e27f3b25e85c39c1a6cb7935435393844ec13c001",
205
- "workflow-local-contracts.mjs": "bc52a461727b34c72daa058e1aa77d58206eafad14a93a347537f9969ae51184",
206
+ "workflow-local-runner.mjs": "baeb0178aa9260f2e02c5c8bc3f1a4934827a5552d0db18fe8ad6acfee579459",
207
+ "workflow-local-contracts.mjs": "ed7807c6230c3f9a2bd7c7a0ca255aec5e8e922fe71a75b95904d87709a615b6",
206
208
  "workflow-local-client.mjs": "45c954f694d96bf0ea87ee2dc6902a6763dac2cb1df713b6a5e749632054e9af",
207
209
  "workflow-local-request.mjs": "4e24c543ce386292a87dc9cd41f90d23375f12079cacffc515c8cd01fd571780",
208
210
  "task-message-entry.mjs": "31d10056cb905237cd5649e8eb067009ff1612ec5dc1061e0373209fbc0b646a",
@@ -237,7 +239,7 @@
237
239
  "local-mutation-files.mjs": "087b00e8424a135e783eb432271012a71e55eeb4a5e30119490d1bc0b2160320",
238
240
  "local-mutation-snapshot.mjs": "f9077539680fb8d3ff33a962657756a8fe6afa8409b6191334509534216ebfe8",
239
241
  "local-mutation-audit.mjs": "5a30ee3446f632e032ffcfe46bcd97d6dfc0f099a76ea5185c3f9a9118c4f6cb",
240
- "local-mutation-wire.mjs": "7af0c585e8dd6936c0ff17f491c64a198744629f063279c31589d887ec83ae9c",
242
+ "local-mutation-wire.mjs": "52f3e4bd90e246abd47163199fed508d9a243770580bb8617bbf58f1da28e01f",
241
243
  "local-mutation-binding.mjs": "4440d792453a07a375b635fa5a91db607e58162824baced5c79a9a513f4ec918",
242
244
  "local-mutation-pass-store.mjs": "1edc5fcc537dfa04b8ae8b07af554d61ea5911a5da281423ad57214ccf753ad4",
243
245
  "local-mutation-authority.mjs": "ebac0cc8126969bafb0aaaeacf71e05124997d5c3214f8ec32b7a4fa14569bcf",
package/release.mjs CHANGED
@@ -38,6 +38,7 @@ const memberContinuationSourceFiles = Object.freeze([
38
38
  'local-approved-process.mjs',
39
39
  'local-approved-supervisor.mjs',
40
40
  'local-mutation-consumption.mjs',
41
+ 'workflow-local-acceptance.mjs',
41
42
  'workflow-observation-client.mjs',
42
43
  'workflow-observation-connection.mjs',
43
44
  'workflow-observation-remote.mjs',
package/releases.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.1.58",
4
+ "date": "2026-10-17",
5
+ "zh": "修复截图等多模态 PostToolUse 事件超过旧输入上限时被误拦:完整读取并严格校验最多 32 MiB 的 UTF-8 JSON 对象,超限或无效事件返回固定安全诊断;保留原授权、待回执调用及 O 证据,私有状态文件仍限 1,000,000 字节。",
6
+ "en": "Fixed multimodal PostToolUse events such as screenshots being rejected by the old input limit. Read and strictly validate the complete UTF-8 JSON object up to 32 MiB, returning fixed safe diagnostics for oversized or invalid events while preserving original authority, pending calls and O evidence. Private state files retain their 1,000,000-byte limit."
7
+ },
8
+ {
9
+ "version": "0.1.57",
10
+ "date": "2026-10-17",
11
+ "zh": "云端关闭时以原本地 IDE 执行与实际测试完成本地验收,补齐签名步骤续跑、最终验收、钩子与队列恢复;保留原范围、审批和 S/M 证据,明确未做独立验证。修复原规划字段诊断与沙箱模式恢复。",
12
+ "en": "Completed explicit local IDE acceptance with real execution and tests when cloud sandbox is disabled, including signed step progress, final acceptance, hooks and queue recovery while preserving original scope, approvals and S/M evidence without claiming independent verification. Added precise planning diagnostics and original sandbox-mode recovery."
13
+ },
2
14
  {
3
15
  "version": "0.1.56",
4
16
  "date": "2026-10-17",
@@ -1,4 +1,4 @@
1
- const ide = ['ide-code-mode.mjs', 'ide-command.mjs', 'ide-hook.mjs', 'ide-install.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-queue.mjs'];
1
+ const ide = ['ide-code-mode.mjs', 'ide-command.mjs', 'ide-hook.mjs', 'ide-install.mjs', 'ide-session.mjs', 'break-glass.mjs', 'ide-results.mjs', 'ide-local-acceptance.mjs', 'ide-queue.mjs'];
2
2
  const discipline = ['local-discipline-actions.mjs', 'local-discipline-analysis.mjs', 'local-discipline-batch.mjs',
3
3
  'local-typescript-function-reference.mjs', 'local-scope-import-expressions.mjs',
4
4
  'local-discipline-contracts.mjs', 'local-discipline-contracts.d.mts', 'local-discipline-gate.mjs',
@@ -5,6 +5,7 @@ import { canonical, sha256, verifySnapshotManifest } from './local-integrity.mjs
5
5
  import { withStateLock } from './local-state-lock.mjs';
6
6
  import { mutationStateRoot14, atomicMutationJson14, privateMutationDirectory14 } from './local-mutation-files.mjs';
7
7
  import { resumeSnapshotRemoval, removeSnapshotWithJournal } from './snapshot-cleanup-journal.mjs';
8
+ import { verifiedLocalWorkflowCompletion } from './ide-local-acceptance.mjs';
8
9
 
9
10
  const id = value => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
10
11
  async function realDirectory(directory) {
@@ -24,9 +25,14 @@ async function safeTree(directory) {
24
25
  }
25
26
  return bytes;
26
27
  }
27
- function completedProof(call, result) {
28
+ async function completedProof(call, result, home) {
28
29
  if (call.operation !== 'continue' || call.input?.operation !== 'workflow-verdict' || !id(call.input.flowId)) return null;
29
30
  const output = result?.output, receipt = result?.feedback;
31
+ if (await verifiedLocalWorkflowCompletion(call, result, home)) return { flowId: output.flowId, requestId: output.requestId,
32
+ receiptId: receipt.receiptId, evaluationDigest: receipt.evaluation.digest,
33
+ verificationMode: 'member-local', independentValidation: 'not-performed' };
34
+ if (output?.localAcceptance || output?.result?.localAcceptance || output?.status === 'local-accepted'
35
+ || output?.result?.status === 'local-accepted') return null;
30
36
  const reports = output?.result?.reports ?? output?.reports;
31
37
  if (output?.status !== 'succeeded' || output.flowId !== call.input.flowId || !Array.isArray(reports) || !reports.length
32
38
  || reports.some(report => !['pass','pass-with-risk'].includes(report.verdict)) || receipt?.requestId !== output.requestId
@@ -74,7 +80,7 @@ async function cleanupRepository(root, repository, proof) {
74
80
  return { removed, retained: [...retained].sort(), releasedBytes };
75
81
  }
76
82
  export async function cleanupCompletedSnapshots(call, result, { home = os.homedir() } = {}) {
77
- const proof = completedProof(call, result);
83
+ const proof = await completedProof(call, result, home);
78
84
  if (!proof) return { status: 'preserved', reason: 'verified-original-completion-required' };
79
85
  const root = await mutationStateRoot14({ home }), snapshotRoot = path.join(root, 'snapshots');
80
86
  if (!await realDirectory(snapshotRoot)) return { status: 'completed', removed: 0, releasedBytes: 0 };
@@ -0,0 +1,35 @@
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
+ import { observationExact, observationFail, observationAuthoritySignature } from './workflow-observation-wire.mjs';
3
+
4
+ const identity = ['flowId', 'ownerId', 'taskId', 'taskDigest', 'chainDigest', 'planId', 'planDigest',
5
+ 'stepId', 'executionPlanSha256', 'leaseId', 'generation', 'observationId'];
6
+ const digestFields = ['taskDigest', 'chainDigest', 'planDigest', 'executionPlanSha256', 'leaseDigest', 'admissionDigest', 'resultDigest'];
7
+ const checks = ['sourceManifestUnchanged', 'protectedFilesUnchanged', 'repositoryUnchanged'];
8
+ export function verifyWorkflowLocalAcceptance(value, admission, result, reportRequestId) {
9
+ observationExact(value, ['schemaVersion', 'status', 'verificationMode', 'independentValidation', 'runnerKeyId',
10
+ ...identity, 'leaseDigest', 'admissionDigest', 'resultDigest', 'requestId', 'acceptedAt', 'authorityReceipt']);
11
+ observationAuthoritySignature(value);
12
+ if (value.schemaVersion !== 'entrorove.member-local-step-acceptance/1.0' || value.status !== 'local-accepted'
13
+ || value.verificationMode !== 'member-local' || value.runnerKeyId !== null || value.independentValidation !== 'not-performed'
14
+ || identity.some(field => value[field] !== admission[field])
15
+ || digestFields.some(field => typeof value[field] !== 'string' || !/^[a-f0-9]{64}$/.test(value[field]))
16
+ || value.admissionDigest !== sha256(canonical(admission)) || value.resultDigest !== sha256(canonical(result))
17
+ || !/^[a-f0-9]{64}$/.test(reportRequestId ?? '') || value.requestId !== reportRequestId
18
+ || typeof value.acceptedAt !== 'string' || !Number.isFinite(Date.parse(value.acceptedAt))
19
+ || new Date(value.acceptedAt).toISOString() !== value.acceptedAt) {
20
+ observationFail('Local acceptance lost its original signed admission, process result, lease or verification role');
21
+ }
22
+ observationExact(result.checks, checks);
23
+ const process = result.process;
24
+ if (result.schemaVersion !== 'entrorove.local-observation-result/1.0' || result.status !== 'incomplete'
25
+ || result.runner !== 'local' || result.receipt !== null || result.independentValidation !== 'pending'
26
+ || result.admissionDigest !== value.admissionDigest || result.observationId !== value.observationId
27
+ || result.sourceManifestDigest !== sha256(canonical(admission.sourceManifest))
28
+ || result.executionIsolation !== 'host-permissions' || result.externalEffects !== 'unverified'
29
+ || process?.started !== true || process.status !== 'succeeded' || process.exitCode !== 0 || process.signal !== null
30
+ || process.timedOut !== false || process.outputLimitExceeded !== false || checks.some(field => result.checks[field] !== true)
31
+ || !/^[a-f0-9]{64}$/.test(result.beforeRepositoryDigest ?? '') || result.beforeRepositoryDigest !== result.afterRepositoryDigest) {
32
+ observationFail('Local acceptance cannot replace a failed, changed or independently unverified process outcome');
33
+ }
34
+ return value;
35
+ }
@@ -40,11 +40,19 @@ export const WORKFLOW_EXECUTION_PLAN_SCHEMA = exact({ schemaVersion: { const: 'v
40
40
  });
41
41
  export const WORKFLOW_ACCEPTED_PLAN_SCHEMA = exact({ schemaVersion: { const: 'entrorove.workflow-local-plan/1.0' },
42
42
  planId: id, flowId: id, ownerId: id, taskDigest: sha256, chainDigest: sha256, chainRevision: integer(1),
43
- acceptedAt: timestamp, expiresAt: timestamp, runnerKeyId: sha256,
43
+ acceptedAt: timestamp, expiresAt: timestamp, runnerKeyId: { anyOf: [sha256, { type: 'null' }] },
44
44
  steps: boundedArray(exact({ stepId: id, sequence: integer(1, 128),
45
45
  dependsOn: { ...boundedArray(id, 0, 128), uniqueItems: true }, executionPlan: WORKFLOW_EXECUTION_PLAN_SCHEMA,
46
46
  executionPlanSha256: sha256 }), 1, 128), authorityReceipt,
47
47
  });
48
+ WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.verificationMode = enumeration(['independent', 'member-local']);
49
+ WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeKeyId = sha256;
50
+ WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.memberScopeBindingDigest = sha256;
51
+ WORKFLOW_ACCEPTED_PLAN_SCHEMA.allOf = [{
52
+ if: { properties: { verificationMode: { const: 'member-local' } }, required: ['verificationMode'] },
53
+ then: { properties: { runnerKeyId: { type: 'null' } }, required: ['memberScopeKeyId', 'memberScopeBindingDigest'] },
54
+ else: { properties: { runnerKeyId: sha256, memberScopeKeyId: false, memberScopeBindingDigest: false } },
55
+ }];
48
56
  WORKFLOW_ACCEPTED_PLAN_SCHEMA.properties.blueprintBinding = exact({
49
57
  schemaVersion: { const: 'entrorove.blueprint-plan-binding/1.0' }, blueprintId: id, revision: integer(0),
50
58
  blueprintSha256: { type: 'string', pattern: '^sha256:[0-9a-f]{64}$' }, machineTasksDigest: sha256,
@@ -54,7 +62,16 @@ export const WORKFLOW_LOCAL_LEASE_SCHEMA = exact({ schemaVersion: { const: 'entr
54
62
  chainRevision: integer(1), stepId: id, sequence: integer(1, 128), generation: integer(1), issuedAt: timestamp, expiresAt: timestamp,
55
63
  verifiedDependencies: { ...boundedArray(exact({ stepId: id, evidenceDigest: sha256 }), 0, 128), uniqueItems: true }, authorityReceipt,
56
64
  });
65
+ WORKFLOW_LOCAL_LEASE_SCHEMA.properties.localDependencies = { ...boundedArray(exact({ stepId: id, acceptanceDigest: sha256 }), 0, 128), uniqueItems: true };
66
+ WORKFLOW_LOCAL_LEASE_SCHEMA.allOf = [{ if: { required: ['localDependencies'] },
67
+ then: { properties: { verifiedDependencies: { type: 'array', maxItems: 0 } } } }];
57
68
  export const WORKFLOW_LOCAL_OPERATION_SCHEMA = object({ repositoryRoot: absolutePath, home: absolutePath,
58
69
  acceptedPlan: WORKFLOW_ACCEPTED_PLAN_SCHEMA, lease: WORKFLOW_LOCAL_LEASE_SCHEMA, stepId: id,
59
70
  approvalPath: absolutePath, signerConfigPath: absolutePath,
60
71
  }, ['repositoryRoot', 'acceptedPlan', 'lease', 'stepId', 'approvalPath', 'signerConfigPath']);
72
+ WORKFLOW_LOCAL_OPERATION_SCHEMA.allOf = [{
73
+ if: { properties: { acceptedPlan: { type: 'object', properties: { verificationMode: { const: 'member-local' } }, required: ['verificationMode'] } } },
74
+ then: { properties: { lease: { type: 'object', required: ['localDependencies'],
75
+ properties: { verifiedDependencies: { type: 'array', maxItems: 0 } } } } },
76
+ else: { properties: { lease: { type: 'object', properties: { localDependencies: false } } } },
77
+ }];
@@ -64,6 +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
68
  ...(plan.blueprintBinding ? ['blueprintBinding'] : [])], 'ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan');
68
69
  if (plan.blueprintBinding) {
69
70
  const binding = exact(plan.blueprintBinding, ['schemaVersion', 'blueprintId', 'revision', 'blueprintSha256', 'machineTasksDigest'],
@@ -74,8 +75,12 @@ function validatePlan(plan) {
74
75
  fail('ENTROROVE_WORKFLOW_BLUEPRINT_BINDING', 'Blueprint plan binding is invalid');
75
76
  }
76
77
  }
78
+ const local = plan.verificationMode === 'member-local';
79
+ const validRole = local ? plan.runnerKeyId === null && SHA.test(plan.memberScopeKeyId ?? '') && SHA.test(plan.memberScopeBindingDigest ?? '')
80
+ : (plan.verificationMode === undefined || plan.verificationMode === 'independent') && SHA.test(plan.runnerKeyId ?? '')
81
+ && !Object.hasOwn(plan, 'memberScopeKeyId') && !Object.hasOwn(plan, 'memberScopeBindingDigest');
77
82
  if (plan.schemaVersion !== WORKFLOW_LOCAL_PLAN_SCHEMA || !SHA.test(plan.taskDigest) || !SHA.test(plan.chainDigest)
78
- || !SHA.test(plan.runnerKeyId) || !positive(plan.chainRevision) || !Array.isArray(plan.steps) || !plan.steps.length || plan.steps.length > 128) fail('ENTROROVE_WORKFLOW_PLAN_FIELDS', 'accepted plan contract is invalid');
83
+ || !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');
79
84
  for (const field of ['planId', 'flowId', 'ownerId']) safeId(plan[field], field);
80
85
  const seen = new Set();
81
86
  for (const [index, step] of plan.steps.entries()) {
@@ -280,7 +285,10 @@ async function prepare(input, create = true) {
280
285
  if (input.executionAttemptNonce !== undefined) safeId(input.executionAttemptNonce, 'executionAttemptNonce');
281
286
  if (process.platform === 'win32' || typeof process.getuid !== 'function') fail('ENTROROVE_POSIX_REQUIRED', 'workflow local runner requires POSIX');
282
287
  const configured = publicKey(process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY?.trim(), 'ENTROROVE_WORKFLOW_AUTHORITY_KEY');
283
- const plan = validatePlan(verifyAuthority(input.acceptedPlan, configured)); const lease = verifyAuthority(input.lease, configured);
288
+ const plan = validatePlan(verifyAuthority(input.acceptedPlan, configured));
289
+ if (plan.verificationMode === 'member-local') fail('ENTROROVE_INDEPENDENT_VERIFICATION_REQUIRED',
290
+ 'A member-local plan cannot run through the independent Validator signer entry; use its original workflow-observe-step action');
291
+ const lease = verifyAuthority(input.lease, configured);
284
292
  const step = validateLease(lease, plan, input.stepId);
285
293
  lifetime(plan.acceptedAt, plan.expiresAt, MAX_PLAN_MS, Date.now(), true); lifetime(lease.issuedAt, lease.expiresAt, MAX_LEASE_MS, Date.now(), true);
286
294
  const root = await realDirectory(input.repositoryRoot, 'ENTROROVE_ROOT_INVALID');
@@ -9,6 +9,7 @@ const messages = {
9
9
  ENTROROVE_LOCAL_OBSERVATION_PREREQUISITE: 'Local observation requires cloud sandbox disabled and its original live execution lease',
10
10
  ENTROROVE_SCOPE_RECEIPT_EXPIRED: 'Refresh the original scope evidence and reuse the original observation input',
11
11
  ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED: 'Observe the settled modifications in fresh scope evidence before local testing',
12
+ ENTROROVE_MEMBER_LOCAL_MODE_PREREQUISITE: 'Disable cloud sandbox and reuse only the original cached process report; do not restart its process or source writes',
12
13
  };
13
14
  function blocked(error, input) {
14
15
  const cause = error && typeof error === 'object' ? error : {};
@@ -16,15 +17,19 @@ function blocked(error, input) {
16
17
  ? cause.code : 'ENTROROVE_LOCAL_OBSERVATION';
17
18
  const output = cause.output, requestId = safeRequest(cause.requestId) ? cause.requestId : undefined;
18
19
  const recoverable = output?.status === 'blocked' && output.recoverable === true
19
- && (output.observationPrerequisite?.effect === 'none' || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED',
20
+ && (output.observationPrerequisite?.effect === 'none' || code === 'ENTROROVE_MEMBER_LOCAL_MODE_PREREQUISITE'
21
+ && output.effects === 'none' && output.localModePrerequisite?.originalOperation === 'member-observation-report'
22
+ && output.localModePrerequisite.serverEffect === 'none' && output.localModePrerequisite.localProcessMayRestart === false
23
+ && output.localModePrerequisite.cachedLocalProcessResultRequired === true || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED',
20
24
  'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED', 'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(code));
21
- const reason = ['sandbox-enabled', 'lease-expired', 'receipt-expired', 'after-write-observation-required'].includes(output?.reason)
25
+ const reason = ['sandbox-enabled', 'cloud-sandbox-enabled', 'lease-expired', 'receipt-expired', 'after-write-observation-required'].includes(output?.reason)
22
26
  ? output.reason : undefined;
23
27
  return { schemaVersion: 'entrorove.local-observation-connection/1.0', status: 'blocked',
24
28
  ...(safeId(input?.flowId) ? { flowId: input.flowId } : {}), independentValidation: 'pending', code,
25
29
  message: messages[code] ?? 'Local observation could not continue; its original command and result remain preserved',
26
30
  ...(recoverable ? { prerequisite: { status: 'blocked', code, recoverable: true, ...(reason ? { reason } : {}),
27
31
  nextAction: { operation: 'workflow-observe-step', reuseOriginalInput: true,
32
+ ...(code === 'ENTROROVE_MEMBER_LOCAL_MODE_PREREQUISITE' ? { localProcessMayRestart: false, cachedLocalProcessResultRequired: true } : {}),
28
33
  ...(safeRequest(output.requestId) ? { resumeRequestId: output.requestId } : {}) } } } : {}),
29
34
  ...(cause.outcomeUnknown === true && requestId ? { outcomeUnknown: true, requestId, repeatPost: false,
30
35
  nextAction: { operation: 'workflow-observe-step', reuseOriginalInput: true, method: 'GET' } } : {}),
@@ -5,6 +5,8 @@ import { observationAdmission, observationFail } from './workflow-observation-wi
5
5
  import { observationLocation, readObservationState } from './workflow-observation-state.mjs';
6
6
  import { runWorkflowObservation } from './workflow-observation-runner.mjs';
7
7
  import { observationRemote, authorizeObservation } from './workflow-observation-remote.mjs';
8
+ import { verifyWorkflowLocalAcceptance } from './workflow-local-acceptance.mjs';
9
+ import { buildInvocation } from './remote-client.mjs';
8
10
 
9
11
  async function connect(input, state, location, dependencies) {
10
12
  const fields = ['taskId', 'flowId', 'stepId', 'leaseId', 'generation', ...(Object.hasOwn(input, 'settlementId') ? ['settlementId'] : [])];
@@ -22,12 +24,25 @@ async function connect(input, state, location, dependencies) {
22
24
  const reported = state.reported ?? await observationRemote('reported', 'member-observation-report', {
23
25
  observationId: admission.observationId, admissionDigest: sha256(canonical(admission)), result,
24
26
  }, state, location, dependencies);
25
- if (reported.observationId !== admission.observationId || reported.observationStatus !== 'pending-independent'
27
+ if (reported.observationId !== admission.observationId || !['pending-independent', 'local-accepted'].includes(reported.observationStatus)
26
28
  || reported.resultDigest !== sha256(canonical(result)) || canonical(reported.result) !== canonical(result)) {
27
29
  observationFail('Stored local observation differs from its actual original result');
28
30
  }
29
- return { ...result, taskId: input.taskId, flowId: input.flowId, stepId: input.stepId, cached, stored: true,
31
+ const summary = { ...result, taskId: input.taskId, flowId: input.flowId, stepId: input.stepId, cached, stored: true,
30
32
  resultDigest: sha256(canonical(result)), grantsExecutionAuthority: false, grantsValidatorAuthority: false };
33
+ if (reported.observationStatus !== 'local-accepted') {
34
+ if (Object.hasOwn(reported, 'localAcceptance')) observationFail('A pending observation cannot claim local acceptance');
35
+ return summary;
36
+ }
37
+ const requestId = buildInvocation('member-observation-report', {
38
+ observationId: admission.observationId, admissionDigest: sha256(canonical(admission)), result,
39
+ }).requestId;
40
+ const localAcceptance = verifyWorkflowLocalAcceptance(reported.localAcceptance, admission, result, requestId);
41
+ if (reported.status !== 'local-accepted' || reported.independentValidation !== 'not-performed') {
42
+ observationFail('Local acceptance output cannot change its distinct independent verification status');
43
+ }
44
+ return { ...summary, status: 'local-accepted', localObservationStatus: 'local-accepted', independentValidation: 'not-performed',
45
+ localAcceptance, result: structuredClone(result) };
31
46
  }
32
47
  export async function connectWorkflowObservation(repositoryRoot, original, dependencies) {
33
48
  const location = await observationLocation(repositoryRoot, original, dependencies.home, trustedOrigin(dependencies.apiOrigin()));
@@ -1,4 +1,4 @@
1
- import { canonical } from './local-integrity.mjs';
1
+ import { canonical, sha256 } from './local-integrity.mjs';
2
2
  import { buildInvocation, invokeRemoteResult } from './remote-client.mjs';
3
3
  import { requestTransport, parseTransportResponse } from './remote-transport.mjs';
4
4
  import { ideClientHeaders } from './ide-presentation.mjs';
@@ -25,7 +25,7 @@ async function recover(pending, dependencies) {
25
25
  response = await requestTransport(origin, `/api/skill/status/${pending.requestId}`, { headers: {
26
26
  authorization: `Bearer ${token}`, ...ideClientHeaders() } });
27
27
  result = parseTransportResponse(response, 'original local observation status');
28
- if (!response.ok || result?.output?.requestId !== pending.requestId
28
+ if (result?.output?.requestId !== pending.requestId || !response.ok && response.status >= 500
29
29
  || ['pending', 'running'].includes(result.output.status)) throw unknown(pending.requestId);
30
30
  result.feedback ??= await queryAutomaticFeedback(pending.requestId, dependencies);
31
31
  verifyAutomaticFeedback(result.feedback, pending.requestId, pending.requestId);
@@ -38,6 +38,19 @@ function prerequisite(output) {
38
38
  || ['ENTROROVE_SCOPE_RECEIPT_EXPIRED', 'ENTROROVE_SCOPE_RUNNER_REQUIRED', 'ENTROROVE_SCOPE_SNAPSHOT_REQUIRED',
39
39
  'ENTROROVE_SCOPE_AFTER_WRITE_REQUIRED'].includes(output.code));
40
40
  }
41
+ function cachedReportPrerequisite(result, pending, state, location) {
42
+ const output = result?.output, proof = output?.localModePrerequisite, next = output?.nextAction;
43
+ return pending.operation === 'member-observation-report' && state.result
44
+ && canonical(pending.input.result) === canonical(state.result) && output?.requestId === pending.requestId
45
+ && output.flowId === location.identity.flowId && output.status === 'blocked' && output.recoverable === true
46
+ && output.code === 'ENTROROVE_MEMBER_LOCAL_MODE_PREREQUISITE' && output.effects === 'none'
47
+ && output.reason === 'cloud-sandbox-enabled' && proof?.serverEffect === 'none'
48
+ && proof.originalOperation === pending.operation && proof.newSourceWritesByThisRequest === false
49
+ && proof.localProcessMayRestart === false && proof.cachedLocalProcessResultRequired === true
50
+ && next?.kind === 'disable-cloud-sandbox-then-resume-original-request' && next.originalOperation === pending.operation
51
+ && next.reuseOriginalInput === true && next.resumeRequestId === pending.requestId && next.preserveOriginalFlow === true
52
+ && result.feedback?.acknowledgement?.stored === true && result.feedback.resultDigest === sha256(canonical(output));
53
+ }
41
54
  export async function observationRemote(stage, operation, input, state, location, dependencies) {
42
55
  const requestId = buildInvocation(operation, input).requestId;
43
56
  let response, result;
@@ -58,8 +71,10 @@ export async function observationRemote(stage, operation, input, state, location
58
71
  throw unknown(requestId);
59
72
  }
60
73
  }
61
- if (!response.ok || !['reported', 'succeeded'].includes(result?.output?.status)) {
62
- if (operation === 'member-observation-admit' && prerequisite(result?.output)) {
74
+ const statuses = ['reported', 'succeeded', ...(operation === 'member-observation-report' ? ['local-accepted'] : [])];
75
+ if (!response.ok || !statuses.includes(result?.output?.status)) {
76
+ if (operation === 'member-observation-admit' && prerequisite(result?.output)
77
+ || cachedReportPrerequisite(result, state.pending, state, location)) {
63
78
  delete state.pending; await saveObservationState(location, state);
64
79
  throw Object.assign(new Error(result.output.message ?? 'Original local observation prerequisite is unresolved'),
65
80
  { code: result.output.code, output: result.output });
@@ -38,7 +38,7 @@ function files(values, modes) {
38
38
  && (!Number.isSafeInteger(file.mode) || file.mode < 0 || file.mode > 511)) observationFail('Local observation file baseline is invalid');
39
39
  }
40
40
  }
41
- function signature(value) {
41
+ export function observationAuthoritySignature(value) {
42
42
  try {
43
43
  const encoded = process.env.ENTROROVE_WORKFLOW_PLAN_PUBLIC_KEY, der = Buffer.from(encoded ?? '', 'base64');
44
44
  const key = createPublicKey({ key: der, type: 'spki', format: 'der' }), receipt = value.authorityReceipt;
@@ -58,7 +58,7 @@ export function observationAdmission(value, input, { allowExpired = false } = {}
58
58
  'chainDigest', 'epoch', 'planId', 'planDigest', 'executionPlanSha256', 'repositoryDigest',
59
59
  ...(readOnly ? ['sourceKind'] : ['settlementDigest']), 'command',
60
60
  'sourceManifest', 'protectedFiles', 'issuedAt', 'expiresAt', 'authorityReceipt']);
61
- signature(value);
61
+ observationAuthoritySignature(value);
62
62
  const start = Date.parse(value.issuedAt), end = Date.parse(value.expiresAt);
63
63
  if (value.schemaVersion !== 'entrorove.local-observation-admission/1.0' || value.purpose !== 'observe-approved-command'
64
64
  || value.epoch !== 0 || fields.some(field => value[field] !== input[field])