@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +709 -0
- package/bin/w2m-grant.mjs +98 -0
- package/bin/w2m-maintenance.mjs +112 -0
- package/codex/mcp-servers.toml +1 -1
- package/deploy/maintenance/bootstrap.ps1 +30 -0
- package/deploy/maintenance/bootstrap.sh +61 -0
- package/deploy/server/w2m-maintenance.path +10 -0
- package/deploy/server/w2m-maintenance.service +20 -0
- package/deploy/windows/w2m-maintenance.xml +31 -0
- package/docs/COLLAB/COLLABORATION-MODEL.md +240 -0
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +361 -0
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +70 -0
- package/docs/COLLAB/M1-MAINTENANCE.md +313 -0
- package/docs/COLLAB/M2-TASK-GRANT.md +231 -0
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +291 -0
- package/docs/COLLAB/M4-PROJECT-ROOM.md +260 -0
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +263 -0
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +231 -0
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +263 -0
- package/docs/COLLAB/TASK-MODEL.md +281 -0
- package/docs/MULTI-MACHINE-COLLABORATION-PLAN.md +186 -0
- package/docs/RELEASE-STABLE.md +193 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-DIAG.json +27 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-POSTUPDATE-DIAG.json +18 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-RESTART.json +24 -0
- package/docs/acceptance/1.3.10-beta.10/M1-LIVE-UPDATE.json +24 -0
- package/docs/acceptance/1.3.11/M1-LIVE-DIAG.json +26 -0
- package/docs/acceptance/1.3.12-beta.1/M1-LIVE-DIAG.json +26 -0
- package/docs/acceptance/1.4.0/M1-LIVE-MAINTENANCE.json +20 -0
- package/docs/acceptance/1.4.0/REGRESSION-1.4.0.json +81 -0
- package/package.json +26 -5
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +141 -3
- package/src/agent/grant-gate.mjs +89 -0
- package/src/agent/maintenance-ferry.mjs +111 -0
- package/src/agent/outbox.mjs +138 -0
- package/src/agent/patch-artifact.mjs +53 -0
- package/src/agent/worktree.mjs +118 -0
- package/src/maintenance/actions.mjs +203 -0
- package/src/maintenance/grant.mjs +139 -0
- package/src/maintenance/operator-key.mjs +151 -0
- package/src/maintenance/protocol.mjs +204 -0
- package/src/maintenance/runner.mjs +189 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/blob-sessions.mjs +148 -0
- package/src/relay/merge-queue.mjs +137 -0
- package/src/relay/project-locks.mjs +179 -0
- package/src/relay/project-notifications.mjs +184 -0
- package/src/relay/project-refs.mjs +110 -0
- package/src/relay/project-reviews.mjs +161 -0
- package/src/relay/project-room.mjs +163 -0
- package/src/relay/reliability-metrics.mjs +180 -0
- package/src/relay/server.mjs +382 -1
- 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
|
+
"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.
|
|
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
|
+
}
|
package/src/agent/agent.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|