@twinsearth/w2m-dsh-plugin 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +709 -0
  4. package/bin/w2m-grant.mjs +98 -0
  5. package/bin/w2m-maintenance.mjs +112 -0
  6. package/codex/mcp-servers.toml +1 -1
  7. package/deploy/maintenance/bootstrap.ps1 +30 -0
  8. package/deploy/maintenance/bootstrap.sh +61 -0
  9. package/deploy/server/w2m-maintenance.path +10 -0
  10. package/deploy/server/w2m-maintenance.service +20 -0
  11. package/deploy/windows/w2m-maintenance.xml +31 -0
  12. package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
  13. package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +361 -0
  14. package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
  15. package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
  16. package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
  17. package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
  18. package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
  19. package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
  20. package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
  21. package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
  22. package/docs/COLLAB/TASK-MODEL.md +281 -0
  23. package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
  24. package/docs/RELEASE-STABLE.md +193 -0
  25. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
  26. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
  27. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
  28. package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
  29. package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
  30. package/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
  31. package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
  32. package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
  33. package/package.json +26 -5
  34. package/plugin.json +1 -1
  35. package/src/acceptance/evidence.mjs +79 -0
  36. package/src/agent/agent.mjs +141 -3
  37. package/src/agent/grant-gate.mjs +89 -0
  38. package/src/agent/maintenance-ferry.mjs +111 -0
  39. package/src/agent/outbox.mjs +138 -0
  40. package/src/agent/patch-artifact.mjs +53 -0
  41. package/src/agent/worktree.mjs +118 -0
  42. package/src/maintenance/actions.mjs +203 -0
  43. package/src/maintenance/grant.mjs +139 -0
  44. package/src/maintenance/operator-key.mjs +151 -0
  45. package/src/maintenance/protocol.mjs +204 -0
  46. package/src/maintenance/runner.mjs +189 -0
  47. package/src/plugin/tools.mjs +1 -1
  48. package/src/relay/blob-sessions.mjs +148 -0
  49. package/src/relay/merge-queue.mjs +137 -0
  50. package/src/relay/project-locks.mjs +179 -0
  51. package/src/relay/project-notifications.mjs +184 -0
  52. package/src/relay/project-refs.mjs +110 -0
  53. package/src/relay/project-reviews.mjs +161 -0
  54. package/src/relay/project-room.mjs +163 -0
  55. package/src/relay/reliability-metrics.mjs +180 -0
  56. package/src/relay/server.mjs +382 -1
  57. package/src/relay/state.mjs +308 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -64,14 +64,14 @@
64
64
  "pack": "node scripts/pack.mjs",
65
65
  "reachability": "node scripts/reachability-check.mjs",
66
66
  "reachability:classify": "node scripts/reachability-classify.mjs",
67
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs",
67
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs test/maintenance-ferry.test.mjs test/grant.test.mjs test/grant-gate.test.mjs test/worktree.test.mjs test/patch-artifact.test.mjs test/merge-queue.test.mjs test/m3-workflow.test.mjs test/project-room.test.mjs test/project-attribution.test.mjs test/project-locks.test.mjs test/project-reviews.test.mjs test/project-notifications.test.mjs test/blob-sessions.test.mjs test/outbox.test.mjs test/reliability-metrics.test.mjs test/evidence.test.mjs test/grant-issue.test.mjs test/project-refs.test.mjs",
68
68
  "test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
69
69
  "test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
70
70
  "test:account-join": "node --test --test-force-exit test/account-join.test.mjs",
71
71
  "test:admission": "node --test --test-force-exit test/admission.test.mjs",
72
72
  "test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
73
73
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
74
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs",
74
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs test/exec-env-injection.test.mjs test/account-join.test.mjs test/fleet-messages.test.mjs test/message-shards.test.mjs test/fleet-messages-http.test.mjs test/message-notify.test.mjs test/update-channels.test.mjs test/update-prerelease.test.mjs test/session-watch.test.mjs test/session-readonly.test.mjs test/session-sync.test.mjs test/fleet-chat-identity.test.mjs test/identity-reader.test.mjs test/intents.test.mjs test/dataflow.test.mjs test/council-fleet.test.mjs test/fleet-memory.test.mjs test/memory-view.test.mjs test/schema-spec.test.mjs test/fleet-weight-parity.test.mjs test/task-deps.test.mjs test/dataflow-wiring.test.mjs test/task-graph.test.mjs test/ask-store.test.mjs test/turn.test.mjs test/fleet-members.test.mjs test/redact.test.mjs test/policy-matrix.test.mjs test/envelope-signature.test.mjs test/chat-crypto.test.mjs test/reduce-suggestion.test.mjs test/merge-hint.test.mjs test/fleet-chat-route.test.mjs test/maintenance.test.mjs",
75
75
  "test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
76
76
  "test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
77
77
  "test:authority": "node --test --test-force-exit test/authority.test.mjs",
@@ -141,7 +141,27 @@
141
141
  "test:chat-crypto": "node --test --test-force-exit test/chat-crypto.test.mjs",
142
142
  "test:reduce-suggestion": "node --test --test-force-exit test/reduce-suggestion.test.mjs",
143
143
  "test:merge-hint": "node --test --test-force-exit test/merge-hint.test.mjs",
144
- "test:fleet-chat-route": "node --test --test-force-exit test/fleet-chat-route.test.mjs"
144
+ "test:fleet-chat-route": "node --test --test-force-exit test/fleet-chat-route.test.mjs",
145
+ "test:maintenance": "node --test --test-force-exit test/maintenance.test.mjs",
146
+ "test:maintenance-ferry": "node --test --test-force-exit test/maintenance-ferry.test.mjs",
147
+ "test:grant": "node --test --test-force-exit test/grant.test.mjs",
148
+ "test:grant-gate": "node --test --test-force-exit test/grant-gate.test.mjs",
149
+ "test:worktree": "node --test --test-force-exit test/worktree.test.mjs",
150
+ "test:patch-artifact": "node --test --test-force-exit test/patch-artifact.test.mjs",
151
+ "test:merge-queue": "node --test --test-force-exit test/merge-queue.test.mjs",
152
+ "test:m3-workflow": "node --test --test-force-exit test/m3-workflow.test.mjs",
153
+ "test:project-room": "node --test --test-force-exit test/project-room.test.mjs",
154
+ "test:project-attribution": "node --test --test-force-exit test/project-attribution.test.mjs",
155
+ "test:project-locks": "node --test --test-force-exit test/project-locks.test.mjs",
156
+ "test:project-reviews": "node --test --test-force-exit test/project-reviews.test.mjs",
157
+ "test:project-notifications": "node --test --test-force-exit test/project-notifications.test.mjs",
158
+ "test:blob-sessions": "node --test --test-force-exit test/blob-sessions.test.mjs",
159
+ "test:outbox": "node --test --test-force-exit test/outbox.test.mjs",
160
+ "test:metrics": "node --test --test-force-exit test/metrics.test.mjs",
161
+ "test:reliability-metrics": "node --test --test-force-exit test/metrics.test.mjs",
162
+ "test:evidence": "node --test --test-force-exit test/evidence.test.mjs",
163
+ "test:grant-issue": "node --test --test-force-exit test/grant-issue.test.mjs",
164
+ "test:project-refs": "node --test --test-force-exit test/project-refs.test.mjs"
145
165
  },
146
166
  "devDependencies": {
147
167
  "@eslint/js": "^10.0.1",
@@ -178,7 +198,8 @@
178
198
  "w2m-localside": "bin/w2m-localside.mjs",
179
199
  "w2m-mcp": "bin/w2m-mcp.mjs",
180
200
  "w2m-rabbit": "bin/w2m-rabbit.mjs",
181
- "w2m-stun": "bin/w2m-stun.mjs"
201
+ "w2m-stun": "bin/w2m-stun.mjs",
202
+ "w2m-grant": "./bin/w2m-grant.mjs"
182
203
  },
183
204
  "disclosure": {
184
205
  "cloud": true,
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "1.3.1",
4
+ "version": "1.4.0",
5
5
  "description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Acceptance evidence validation (M7, v1.3.10-beta.24 slice).
3
+ *
4
+ * M7 says a capability is accepted only when its evidence is reproducible: a real task id, a real machine, an asserted result,
5
+ * a timestamp and an executor, stored in docs/acceptance/<version>/. This module is the schema those files must satisfy and the
6
+ * summary the release notes read. It validates the document; it cannot validate that the run happened, which is exactly why the
7
+ * remaining fields (task_id, machines, executed_at) are required rather than optional.
8
+ */
9
+ export const EVIDENCE_KINDS = Object.freeze(['positive', 'negative', 'chaos']);
10
+ export const EVIDENCE_EXECUTORS = Object.freeze(['lead', 'human', 'agent']);
11
+
12
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
13
+ const isArtifactSha = (value) => typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
14
+ const isIsoTimestamp = (value) => typeof value === "string" && !Number.isNaN(Date.parse(value)) && /^\d{4}-\d{2}-\d{2}T/.test(value);
15
+
16
+ /**
17
+ * Validate one evidence record. Returns every problem, not just the first: a release check should list what to fix.
18
+ */
19
+ export function validateEvidence(record) {
20
+ const errors = [];
21
+ const fail = (code, field, message) => errors.push({ code, field, message });
22
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
23
+ return { ok: false, errors: [{ code: "EVIDENCE_NOT_OBJECT", field: null, message: "evidence must be a JSON object" }] };
24
+ }
25
+ if (!isNonEmptyString(record.version)) fail("VERSION_MISSING", "version", "version is required");
26
+ if (!isNonEmptyString(record.case) || !/^[A-Z0-9][A-Z0-9-]{0,79}$/.test(record.case)) fail("CASE_INVALID", "case", "case must be an uppercase id such as M1-LIVE-DIAG");
27
+ if (!EVIDENCE_KINDS.includes(record.kind)) fail("KIND_INVALID", "kind", "kind must be positive, negative or chaos");
28
+ if (!isNonEmptyString(record.requirement)) fail("REQUIREMENT_MISSING", "requirement", "requirement is required");
29
+ if (!Array.isArray(record.machines) || record.machines.length === 0 || !record.machines.every(isNonEmptyString)) fail("MACHINES_INVALID", "machines", "machines must be a non-empty array of machine ids");
30
+ if (!isIsoTimestamp(record.executed_at)) fail("EXECUTED_AT_INVALID", "executed_at", "executed_at must be an ISO timestamp");
31
+ if (!EVIDENCE_EXECUTORS.includes(record.executor)) fail("EXECUTOR_INVALID", "executor", "executor must be lead, human or agent");
32
+ if (record.commands !== undefined) {
33
+ const commandsValid = Array.isArray(record.commands) && record.commands.every((argv) => Array.isArray(argv) && argv.length > 0 && argv.every(isNonEmptyString));
34
+ if (!commandsValid) fail("COMMANDS_INVALID", "commands", "commands must be an array of argv arrays");
35
+ }
36
+ if (record.ledger_seq !== undefined) {
37
+ const valid = Array.isArray(record.ledger_seq) && record.ledger_seq.length === 2 && record.ledger_seq.every((value) => Number.isInteger(value) && value >= 0) && record.ledger_seq[0] <= record.ledger_seq[1];
38
+ if (!valid) fail("LEDGER_SEQ_INVALID", "ledger_seq", "ledger_seq must be [from, to] with from <= to");
39
+ }
40
+ if (record.artifacts !== undefined) {
41
+ const valid = Array.isArray(record.artifacts) && record.artifacts.every((artifact) => artifact !== null && typeof artifact === "object" && isArtifactSha(artifact.sha256) && Number.isInteger(artifact.bytes) && artifact.bytes >= 0);
42
+ if (!valid) fail("ARTIFACTS_INVALID", "artifacts", "artifacts must be [{sha256, bytes}]");
43
+ }
44
+ if (record.result === null || typeof record.result !== "object" || Array.isArray(record.result)) {
45
+ fail("RESULT_MISSING", "result", "result object is required");
46
+ } else {
47
+ // A record may carry the verdict at the top level or under a nested task object (the live restart evidence does the latter),
48
+ // because both are the same fact; what is not allowed is a result with no verdict anywhere.
49
+ const outcome = isNonEmptyString(record.result.outcome) ? record.result.outcome : (record.result.task !== null && typeof record.result.task === "object" && isNonEmptyString(record.result.task.outcome) ? record.result.task.outcome : "");
50
+ if (outcome === "") fail("RESULT_OUTCOME_MISSING", "result.outcome", "result.outcome (or result.task.outcome) is required");
51
+ if (record.result.assertions !== undefined && (record.result.assertions === null || typeof record.result.assertions !== "object" || Array.isArray(record.result.assertions))) {
52
+ fail("RESULT_ASSERTIONS_INVALID", "result.assertions", "result.assertions must be an object when present");
53
+ }
54
+ }
55
+ if (record.kind === "positive") {
56
+ if (!isNonEmptyString(record.task_id)) fail("TASK_ID_MISSING", "task_id", "a positive case must name the real task it ran");
57
+ } else if (record.task_id !== undefined && record.task_id !== null && !isNonEmptyString(record.task_id)) {
58
+ fail("TASK_ID_INVALID", "task_id", "task_id must be a string when present");
59
+ }
60
+ return { ok: errors.length === 0, errors };
61
+ }
62
+
63
+ /** Counts by kind and version, for a release note or a gate summary. */
64
+ export function summarizeEvidence(records) {
65
+ const byKind = {};
66
+ const byVersion = {};
67
+ const invalid = [];
68
+ for (const record of records) {
69
+ const verdict = validateEvidence(record);
70
+ if (!verdict.ok) {
71
+ invalid.push({ case: record?.case ?? null, errors: verdict.errors });
72
+ continue;
73
+ }
74
+ const version = record.version;
75
+ byKind[record.kind] = (byKind[record.kind] ?? 0) + 1;
76
+ byVersion[version] = (byVersion[version] ?? 0) + 1;
77
+ }
78
+ return { total: records.length, valid: records.length - invalid.length, invalid, byKind, byVersion };
79
+ }
@@ -25,6 +25,10 @@ import { Readable } from 'node:stream';
25
25
  import { access, constants as fsConstants } from 'node:fs/promises';
26
26
  import { existsSync, mkdirSync, mkdtempSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
27
27
  import { dirname, join } from 'node:path';
28
+ import { commitTaskChanges, createWorktree, listChangedFiles, producePatch, removeWorktree } from './worktree.mjs';
29
+ import { describePatchArtifact } from './patch-artifact.mjs';
30
+ import { ferryMaintenance, maintenancePaths } from './maintenance-ferry.mjs';
31
+ import { authorizeWithGrant } from './grant-gate.mjs';
28
32
  import { tmpdir } from 'node:os';
29
33
  import { randomUUID , sign as signBytes } from 'node:crypto';
30
34
 
@@ -2581,6 +2585,8 @@ export function createAgent(options) {
2581
2585
  comparePolicy,
2582
2586
  pipeline,
2583
2587
  protocolViolations,
2588
+ authorization = null,
2589
+ workspace = null,
2584
2590
  } = context;
2585
2591
 
2586
2592
  const stdout = execution?.stdout ?? Buffer.alloc(0);
@@ -2603,6 +2609,11 @@ export function createAgent(options) {
2603
2609
 
2604
2610
  const envelope = {
2605
2611
  envelope_version: ENVELOPE_VERSION,
2612
+ // v1.3.12-beta.3: how this execution was authorised -- 'grant' (operator, one execution), 'allow-list' (standing machine
2613
+ // decision) or 'maintenance' (signed maintenance request). A reader can answer "who allowed this" without the ledger.
2614
+ authorization: authorization ?? null,
2615
+ // v1.3.13-beta.2: the copy this task ran on, and the patch it produced (evidence, not a promise).
2616
+ workspace: workspace ?? null,
2606
2617
  task_id: offer.task_id,
2607
2618
  attempt: Number(offer.attempt) || 1,
2608
2619
  dedupe_key: offer.dedupe_key ?? '',
@@ -2925,6 +2936,9 @@ export function createAgent(options) {
2925
2936
  /** @type {string[]} */
2926
2937
  const warnings = [];
2927
2938
 
2939
+ // Hoisted so the catch below can clean up a worktree even when the failure happened after creation.
2940
+ let workspaceState = null;
2941
+
2928
2942
  try {
2929
2943
  // ---- 3. capability gate (§6.1) -------------------------------------
2930
2944
  const writable = offer.write === true ? await isWritableProject(project) : true;
@@ -2957,7 +2971,27 @@ export function createAgent(options) {
2957
2971
  // `stages[0].command_argv` -- so this check covers the first stage and nothing else. Every
2958
2972
  // stage is checked below, before any of them runs; see the note there for why the second check
2959
2973
  // has to exist even though the plugin's pre-flight also checks every stage.
2960
- const allowed = matchAllowedCommand(commandArgv, allowedCommands);
2974
+ /**
2975
+ * v1.3.10-beta.3: the allow-list is a standing decision; a grant is a decision made now for exactly this argv.
2976
+ * Ask the grant gate before falling through to the allow-list refusal, and never the other way round.
2977
+ */
2978
+ let allowed = matchAllowedCommand(commandArgv, allowedCommands);
2979
+ let grantAuthorized = false;
2980
+ if (allowed.allowed !== true && offer && typeof offer.grant === 'object' && offer.grant !== null) {
2981
+ const gatePaths = maintenancePaths();
2982
+ const gate = await authorizeWithGrant({
2983
+ offer,
2984
+ machineId: String(offer.machine_id ?? identity.machine_id ?? ''),
2985
+ pinFile: gatePaths.pinFile,
2986
+ usedFile: join(dirname(gatePaths.pinFile), 'grant-uses.json'),
2987
+ });
2988
+ if (gate.ok === true) { allowed = { allowed: true, ok: true, grant_id: gate.grantId }; grantAuthorized = true; }
2989
+ else allowed = { ok: false, reason_code: gate.code ?? 'GRANT_REJECTED', detail: gate.message };
2990
+ }
2991
+ // One decision, two audiences: the gate decides whether to run, and the envelope records which decision it was.
2992
+ const authorization = grantAuthorized
2993
+ ? { provenance: 'grant', capability_id: allowed.grant_id ?? null, grant_id: allowed.grant_id ?? null }
2994
+ : { provenance: 'allow-list' };
2961
2995
  if (!allowed.allowed) {
2962
2996
  log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
2963
2997
  await finish({
@@ -3036,6 +3070,50 @@ export function createAgent(options) {
3036
3070
  * a reduce that ran on half the inputs would produce a confident wrong answer, which is the failure this feature exists to
3037
3071
  * make impossible.
3038
3072
  */
3073
+ /**
3074
+ * v1.3.10-beta.2: a maintenance request is verified and carried by maintenance-ferry.mjs, not executed here. The
3075
+ * agent has no privilege to run update/restart/diag, which is the point of the separation.
3076
+ */
3077
+ if (offer && typeof offer.maintenance === 'object' && offer.maintenance !== null) {
3078
+ const ferryStarted = Date.now();
3079
+ const ferriedRaw = await ferryMaintenance({ offer, machineId: String(offer.machine_id ?? identity.machine_id ?? '') });
3080
+ // The ferry successful only means the runner wrote a result file; the runner itself may have failed. Reporting that
3081
+ // as ok would tell the operator a restart/update happened when it did not.
3082
+ const runnerOk = ferriedRaw.ok === true && ferriedRaw.result?.status === 'ok';
3083
+ const ferried = runnerOk ? ferriedRaw : { ok: false, code: ferriedRaw.result?.code ?? ferriedRaw.code ?? 'MAINTENANCE_FAILED', message: ferriedRaw.result?.code ?? ferriedRaw.message ?? 'maintenance runner reported failure', requestId: ferriedRaw.requestId };
3084
+ const stdoutText = JSON.stringify(ferried).slice(0, 64 * 1024);
3085
+ const stderrText = ferried.ok ? '' : (ferried.message ?? ferried.code ?? 'maintenance failed');
3086
+ // The envelope builder hashes and sizes the output; leaving any of these undefined makes canonicalisation fail
3087
+ // with a JCS error and the task is reported as crashed after the maintenance action already ran.
3088
+ const execution = {
3089
+ exit_code: ferried.ok ? 0 : 1,
3090
+ stdout: stdoutText,
3091
+ stderr: stderrText,
3092
+ stdout_sha256: sha256Hex(stdoutText),
3093
+ stderr_sha256: sha256Hex(stderrText),
3094
+ stdout_bytes: Buffer.byteLength(stdoutText, 'utf8'),
3095
+ stderr_bytes: Buffer.byteLength(stderrText, 'utf8'),
3096
+ duration_ms: Date.now() - ferryStarted,
3097
+ timed_out: ferried.code === 'MAINTENANCE_RUNNER_TIMEOUT',
3098
+ };
3099
+ await finish({
3100
+ offer,
3101
+ cwdRel,
3102
+ commandArgv,
3103
+ anchorsBefore,
3104
+ anchorsAfter: null,
3105
+ execution,
3106
+ authorization: { provenance: 'maintenance', capability_id: typeof offer.maintenance?.request_id === 'string' ? offer.maintenance.request_id : null },
3107
+ status: ferried.ok ? 'ok' : 'failed',
3108
+ refusalReason: ferried.ok ? null : 'MAINTENANCE_FAILED',
3109
+ warnings,
3110
+ startedAt,
3111
+ endedAt: new Date().toISOString(),
3112
+ protocolViolations: [],
3113
+ });
3114
+ return;
3115
+ }
3116
+
3039
3117
  let reduceEnv = null;
3040
3118
  if (Array.isArray(offer.reduce_inputs) && offer.reduce_inputs.length > 0) {
3041
3119
  try {
@@ -3080,10 +3158,34 @@ export function createAgent(options) {
3080
3158
  }
3081
3159
  }
3082
3160
 
3161
+ /**
3162
+ * v1.3.13-beta.2 (task model): run a workspace task in its own git worktree at the caller base_commit, then turn the
3163
+ * result into a content-addressed patch. Created only after the allow-list/grant gate, so a refused task never leaves a
3164
+ * tree behind; cleaned before the envelope is sent, so the reported evidence describes a finished state.
3165
+ */
3166
+ if (offer.workspace !== null && offer.workspace !== undefined) {
3167
+ if (offer.workspace.kind !== 'worktree') {
3168
+ await finish({ offer, cwdRel, commandArgv, anchorsBefore, anchorsAfter: null, execution: null, status: 'refused', refusalReason: 'WORKSPACE_UNSUPPORTED', warnings: ['WORKSPACE_UNSUPPORTED: only worktree workspaces exist'], startedAt: new Date().toISOString(), endedAt: new Date().toISOString() });
3169
+ return;
3170
+ }
3171
+ const workspaceDirName = String(offer.task_id).replace(/[^A-Za-z0-9._-]/g, '_');
3172
+ const worktreeDir = join(project, '.w2m-worktrees', workspaceDirName);
3173
+ const created = await createWorktree({ repoDir: project, baseCommit: offer.workspace.base_commit, worktreeDir });
3174
+ if (created.ok !== true) {
3175
+ log('warn', `refused ${offer.task_id}: WORKSPACE_CREATE_FAILED`, { code: created.code });
3176
+ await finish({ offer, cwdRel, commandArgv, anchorsBefore, anchorsAfter: null, execution: null, status: 'refused', refusalReason: 'WORKSPACE_CREATE_FAILED', warnings: ['WORKSPACE_CREATE_FAILED: ' + String(created.code ?? 'unknown')], startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), protocolViolations: ['workspace could not be created'] });
3177
+ return;
3178
+ }
3179
+ workspaceState = { worktreeDir, baseCommit: offer.workspace.base_commit, produce: offer.workspace.produce ?? 'patch', relCwd: '.w2m-worktrees/' + workspaceDirName };
3180
+ log('info', `${offer.task_id} workspace ${workspaceState.baseCommit.slice(0, 12)} at ${workspaceState.relCwd}`);
3181
+ }
3182
+
3083
3183
  for (const stage of plannedStages) {
3084
- const stageAllowed = matchAllowedCommand(stage.command_argv, allowedCommands);
3184
+ // v1.3.10-beta.6: a grant that authorised step 5 covers the whole task, but only for the argv it signed.
3185
+ const stageAllowed = grantAuthorized ? { allowed: true, ok: true } : matchAllowedCommand(stage.command_argv, allowedCommands);
3085
3186
  if (stageAllowed.allowed) continue;
3086
3187
  const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
3188
+ if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
3087
3189
  log('warn', `refused ${offer.task_id}: ${code} at stage ${stage.index}`, {
3088
3190
  argv: stage.command_argv,
3089
3191
  });
@@ -3124,7 +3226,13 @@ export function createAgent(options) {
3124
3226
  for (const stage of plannedStages) {
3125
3227
  heartbeat.setPhase('running', 50);
3126
3228
 
3127
- const stageCwdOutcome = resolveProjectCwd(project, stage.cwd_rel, { allowOutside: offer.cwd_outside_project === true });
3229
+ // A workspace task runs in its own copy: an explicit stage cwd is relative to that copy, and the default is its root.
3230
+ const workspaceRel = workspaceState === null
3231
+ ? null
3232
+ : (stage.cwd_rel === undefined || stage.cwd_rel === cwdRel || stage.cwd_rel === '.')
3233
+ ? workspaceState.relCwd
3234
+ : workspaceState.relCwd + '/' + normalizeRelPath(stage.cwd_rel);
3235
+ const stageCwdOutcome = resolveProjectCwd(project, workspaceRel ?? stage.cwd_rel, { allowOutside: offer.cwd_outside_project === true });
3128
3236
  if (!stageCwdOutcome.ok) {
3129
3237
  // A stage whose cwd is outside the project is a refusal of the whole chain: continuing
3130
3238
  // would run later stages against a working directory the caller never asked for.
@@ -3301,6 +3409,33 @@ export function createAgent(options) {
3301
3409
  if (execution?.timed_out) log('warn', `${offer.task_id} timed out after ${offer.timeout_ms}ms`);
3302
3410
  if (execution?.cancelled) log('warn', `${offer.task_id} was cancelled`);
3303
3411
 
3412
+ let workspaceEvidence = null;
3413
+ if (workspaceState !== null) {
3414
+ const commit = await commitTaskChanges({ worktreeDir: workspaceState.worktreeDir, message: 'task ' + offer.task_id });
3415
+ const patch = await producePatch({ worktreeDir: workspaceState.worktreeDir, baseCommit: workspaceState.baseCommit });
3416
+ const changed = await listChangedFiles({ worktreeDir: workspaceState.worktreeDir, baseCommit: workspaceState.baseCommit });
3417
+ const artifact = patch.ok === true ? describePatchArtifact({ patch: patch.patch, baseCommit: workspaceState.baseCommit, headCommit: commit.ok === true ? (commit.headCommit ?? null) : null, filesChanged: changed.ok === true ? changed.files : [] }) : null;
3418
+ let patchSha = null;
3419
+ if (patch.ok === true && typeof patch.patch === 'string' && patch.patch.trim() !== '') {
3420
+ const sha = sha256Hex(patch.patch);
3421
+ const uploaded = await putBlobBytes(sha, patch.patch);
3422
+ if (uploaded.ok === true) patchSha = sha;
3423
+ else warnings.push('PATCH_UPLOAD_FAILED');
3424
+ }
3425
+ const removed = await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir });
3426
+ if (removed.ok !== true) warnings.push('WORKTREE_CLEANUP_FAILED');
3427
+ workspaceEvidence = {
3428
+ kind: 'worktree',
3429
+ base_commit: workspaceState.baseCommit,
3430
+ produce: workspaceState.produce,
3431
+ head_commit: commit.ok === true ? (commit.headCommit ?? null) : null,
3432
+ changed_files: artifact !== null && artifact.ok === true ? artifact.filesChanged : [],
3433
+ patch_sha256: patchSha,
3434
+ patch_bytes: typeof patch.patch === 'string' ? Buffer.byteLength(patch.patch, 'utf8') : 0,
3435
+ cleaned: removed.ok === true,
3436
+ };
3437
+ }
3438
+
3304
3439
  const anchorsAfter = await safeAnchors(warnings);
3305
3440
  const status = execution ? classifyExit(execution) : (protocolViolations.length > 0 ? 'refused' : 'unverifiable');
3306
3441
 
@@ -3311,6 +3446,8 @@ export function createAgent(options) {
3311
3446
  anchorsBefore,
3312
3447
  anchorsAfter,
3313
3448
  execution,
3449
+ authorization,
3450
+ workspace: workspaceEvidence,
3314
3451
  blobSha256: lastBlobSha256,
3315
3452
  status,
3316
3453
  refusalReason: null,
@@ -3333,6 +3470,7 @@ export function createAgent(options) {
3333
3470
  // Never leave a claimed task unspooled: emit a crashed envelope so the
3334
3471
  // relay can account for it instead of waiting for a lease to expire.
3335
3472
  log('error', `error while handling ${offer.task_id}`, { error: error?.message ?? String(error) });
3473
+ if (workspaceState !== null) { try { await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir }); } catch { /* best effort */ } }
3336
3474
  try {
3337
3475
  const envelope = makeEnvelope({
3338
3476
  offer,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Agent-side grant gate (M2, v1.3.10-beta.3 slice).
3
+ *
4
+ * The allow-list is a standing decision; a grant is a decision made now. This gate is what the agent asks before it falls
5
+ * through to the allow-list refusal: is there a valid signed grant for exactly this argv, on this machine, for this task,
6
+ * still inside its window, and not already spent?
7
+ *
8
+ * Verification is delegated to src/maintenance/grant.mjs; this file owns the decision's state (which grants were used) and
9
+ * the operator pin handling (first contact pins, later keys must rotate).
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
12
+ import { dirname } from 'node:path';
13
+ import { verifyGrant } from '../maintenance/grant.mjs';
14
+ import { createPinStore } from '../maintenance/operator-key.mjs';
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {object} options.offer the task offer
19
+ * @param {string} options.machineId
20
+ * @param {string} options.pinFile operator pin store path
21
+ * @param {string} [options.usedFile] persisted grant uses (grant_id -> count, plus nonces)
22
+ * @param {() => number} [options.now]
23
+ * @returns {Promise<{ok: true, grantId: string, used: number} | {ok: false, code: string, message: string, detail?: object}>}
24
+ */
25
+ export async function authorizeWithGrant(options) {
26
+ const { offer, machineId, pinFile, usedFile, now = () => Date.now() } = options;
27
+ const grant = offer?.grant;
28
+ if (grant === null || typeof grant !== 'object') return { ok: false, code: 'GRANT_MISSING', message: 'no grant on the offer' };
29
+
30
+ const store = createPinStore(pinFile);
31
+ let pins;
32
+ try { pins = store.load(); } catch (error) { return { ok: false, code: error?.code ?? 'PINS_CORRUPT', message: error?.message ?? 'pin store unreadable' }; }
33
+ if (pins.pinned === null) {
34
+ if (typeof offer.operator_key?.public_key_pem !== 'string') return { ok: false, code: 'GRANT_NO_PINNED_KEY', message: 'no operator key is pinned and none was supplied' };
35
+ try {
36
+ store.pinFromRegistration({ protocol: 'w2m-operator-key/v1', key_id: offer.operator_key.key_id, public_key_pem: offer.operator_key.public_key_pem, created_at: offer.operator_key.created_at });
37
+ pins = store.load();
38
+ } catch (error) {
39
+ return { ok: false, code: error?.code ?? 'GRANT_PIN_FAILED', message: error?.message ?? 'operator key could not be pinned' };
40
+ }
41
+ }
42
+
43
+ const used = readUsed(usedFile);
44
+ const verified = verifyGrant(grant, {
45
+ publicKeyPem: pins.pinned.public_key_pem,
46
+ expectedKeyId: pins.pinned.key_id,
47
+ nowMs: now(),
48
+ machineId,
49
+ taskId: typeof offer.task_id === 'string' ? offer.task_id : null,
50
+ argv: Array.isArray(offer.command_argv) ? offer.command_argv : [],
51
+ usedGrantIds: new Set(Object.keys(used.grants)),
52
+ usedNonces: new Set(used.nonces),
53
+ usedCount: used.grants[grant.grant_id] ?? 0,
54
+ isRevoked: (keyId) => store.isRevoked(keyId),
55
+ policy: { mode: offer.mode, cwd_rel: offer.cwd_rel, write: offer.write === true, write_scope: offer.write_scope },
56
+ });
57
+ if (verified.ok !== true) return { ok: false, code: verified.code, message: verified.detail?.message ?? 'grant refused', detail: verified.detail };
58
+
59
+ used.grants[grant.grant_id] = (used.grants[grant.grant_id] ?? 0) + 1;
60
+ used.nonces = [...used.nonces, grant.nonce].slice(-500);
61
+ // Fail closed: the use ledger is what makes a single-use grant single-use, so an unwritable ledger must refuse the task
62
+ // rather than run it unrecorded (and rather than crash the whole handler). Seen on a real host where the maintenance state
63
+ // directory was root-owned but the agent ran unprivileged.
64
+ try {
65
+ writeUsed(usedFile, used);
66
+ } catch (error) {
67
+ return { ok: false, code: 'GRANT_LEDGER_UNAVAILABLE', message: 'the grant use ledger cannot be written: ' + String(error?.message ?? error) };
68
+ }
69
+ return { ok: true, grantId: grant.grant_id, used: used.grants[grant.grant_id] };
70
+ }
71
+
72
+ function readUsed(file) {
73
+ if (typeof file !== 'string' || file === '' || !existsSync(file)) return { grants: {}, nonces: [] };
74
+ try {
75
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
76
+ return { grants: parsed.grants && typeof parsed.grants === 'object' ? parsed.grants : {}, nonces: Array.isArray(parsed.nonces) ? parsed.nonces : [] };
77
+ } catch {
78
+ // A corrupt use ledger must not silently reset counts: that would make a spent grant usable again.
79
+ return { grants: { __corrupt__: 1 }, nonces: [] };
80
+ }
81
+ }
82
+
83
+ function writeUsed(file, used) {
84
+ if (typeof file !== 'string' || file === '') return;
85
+ mkdirSync(dirname(file), { recursive: true });
86
+ const tmp = file + '.tmp-' + process.pid;
87
+ writeFileSync(tmp, JSON.stringify({ protocol: 'w2m-grant-uses/v1', ...used }, null, 2) + '\n', 'utf8');
88
+ renameSync(tmp, file);
89
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Agent-side maintenance ferry (v1.3.10-beta.2, M1).
3
+ *
4
+ * The agent is not privileged enough to run update/restart/diag, and it should not be: it carries the signed request to the
5
+ * queue the root runner watches, waits for that runner's result file, and hands the result back to the task. Everything
6
+ * about verification lives here so the agent file only has to call one function.
7
+ *
8
+ * Failure is a value, not an exception: the agent needs to report "refused because GRANT..." as a task result, and a thrown
9
+ * error would lose the reason.
10
+ */
11
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { verifyRequest } from '../maintenance/protocol.mjs';
14
+ import { createPinStore } from '../maintenance/operator-key.mjs';
15
+
16
+ export function maintenancePaths(env = process.env, platform = process.platform) {
17
+ if (platform === 'win32') {
18
+ return {
19
+ queueDir: env.W2M_MAINTENANCE_QUEUE ?? 'C:\\ProgramData\\w2m\\maintenance\\queue',
20
+ doneDir: env.W2M_MAINTENANCE_DONE ?? 'C:\\ProgramData\\w2m\\maintenance\\done',
21
+ pinFile: env.W2M_MAINTENANCE_PINS ?? 'C:\\ProgramData\\w2m\\maintenance\\operator-pins.json',
22
+ };
23
+ }
24
+ return {
25
+ queueDir: env.W2M_MAINTENANCE_QUEUE ?? '/var/lib/w2m/maintenance/queue',
26
+ doneDir: env.W2M_MAINTENANCE_DONE ?? '/var/lib/w2m/maintenance/done',
27
+ pinFile: env.W2M_MAINTENANCE_PINS ?? '/var/lib/w2m/maintenance/operator-pins.json',
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Carry one offer's maintenance request to the root runner and return its result.
33
+ *
34
+ * @param {object} options
35
+ * @param {object} options.offer the task offer (must carry maintenance and may carry operator_key)
36
+ * @param {string} options.machineId this machine's id
37
+ * @param {string} [options.queueDir]
38
+ * @param {string} [options.doneDir]
39
+ * @param {string} [options.pinFile]
40
+ * @param {number} [options.timeoutMs]
41
+ * @param {() => number} [options.now]
42
+ * @param {(ms: number) => Promise<void>} [options.sleep]
43
+ * @returns {Promise<{ok: true, result: object, requestId: string} | {ok: false, code: string, message: string, requestId: string|null}>}
44
+ */
45
+ export async function ferryMaintenance(options) {
46
+ const {
47
+ offer,
48
+ machineId,
49
+ timeoutMs = 120_000,
50
+ now = () => Date.now(),
51
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
52
+ } = options;
53
+ const paths = maintenancePaths(options.env, options.platform);
54
+ const queueDir = options.queueDir ?? paths.queueDir;
55
+ const doneDir = options.doneDir ?? paths.doneDir;
56
+ const pinFile = options.pinFile ?? paths.pinFile;
57
+ const request = offer?.maintenance;
58
+ const requestId = typeof request?.request_id === 'string' ? request.request_id : null;
59
+ const fail = (code, message) => ({ ok: false, code, message, requestId });
60
+
61
+ if (requestId === null) return fail('MAINTENANCE_BAD_SHAPE', 'offer carries no maintenance.request_id');
62
+ const store = createPinStore(pinFile);
63
+ let pins;
64
+ try {
65
+ pins = store.load();
66
+ } catch (error) {
67
+ return fail(error?.code ?? 'PINS_CORRUPT', error?.message ?? 'operator pin store is unreadable');
68
+ }
69
+ if (pins.pinned === null) {
70
+ if (typeof offer.operator_key?.public_key_pem !== 'string') return fail('MAINTENANCE_NO_PINNED_KEY', 'no pinned operator key and none supplied with the request');
71
+ try {
72
+ store.pinFromRegistration({ protocol: 'w2m-operator-key/v1', key_id: offer.operator_key.key_id, public_key_pem: offer.operator_key.public_key_pem, created_at: offer.operator_key.created_at });
73
+ pins = store.load();
74
+ } catch (error) {
75
+ return fail(error?.code ?? 'MAINTENANCE_PIN_FAILED', error?.message ?? 'operator key could not be pinned');
76
+ }
77
+ }
78
+ const verified = verifyRequest(request, {
79
+ publicKeyPem: pins.pinned.public_key_pem,
80
+ expectedKeyId: pins.pinned.key_id,
81
+ expectedMachineId: String(machineId ?? ''),
82
+ nowMs: now(),
83
+ isRevoked: (keyId) => store.isRevoked(keyId),
84
+ });
85
+ if (verified.ok !== true) return fail(verified.code, verified.detail?.message ?? 'maintenance request refused');
86
+
87
+ const requestFile = join(queueDir, String(requestId) + '.json');
88
+ const doneFile = join(doneDir, encodeURIComponent(String(requestId)) + '.json');
89
+ try {
90
+ mkdirSync(queueDir, { recursive: true });
91
+ mkdirSync(doneDir, { recursive: true });
92
+ const tmp = requestFile + '.tmp-' + process.pid;
93
+ writeFileSync(tmp, JSON.stringify(request), 'utf8');
94
+ renameSync(tmp, requestFile);
95
+ } catch (error) {
96
+ return fail('MAINTENANCE_QUEUE_UNAVAILABLE', error?.message ?? 'the maintenance queue directory is not writable');
97
+ }
98
+
99
+ const deadline = now() + Math.max(1_000, Number(timeoutMs) || 120_000);
100
+ while (now() < deadline) {
101
+ if (existsSync(doneFile)) {
102
+ try {
103
+ return { ok: true, result: JSON.parse(readFileSync(doneFile, 'utf8')), requestId };
104
+ } catch (error) {
105
+ return fail('MAINTENANCE_RESULT_UNREADABLE', error?.message ?? 'the runner wrote an unreadable result');
106
+ }
107
+ }
108
+ await sleep(500);
109
+ }
110
+ return fail('MAINTENANCE_RUNNER_TIMEOUT', 'the root runner did not report within the timeout');
111
+ }