@twinsearth/w2m-dsh-plugin 1.3.0 → 1.3.11
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 +601 -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 +59 -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 +250 -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/package.json +22 -4
- package/plugin.json +1 -1
- package/src/acceptance/evidence.mjs +79 -0
- package/src/agent/agent.mjs +64 -2
- package/src/agent/grant-gate.mjs +82 -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 +109 -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 +182 -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 +164 -1
- package/src/relay/state.mjs +128 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.10-beta.10",
|
|
3
|
+
"case": "M1-LIVE-DIAG",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"requirement": "a signed maintenance request is executed by the root runner on a remote machine and its result returns through the fleet",
|
|
6
|
+
"machines": [
|
|
7
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
8
|
+
],
|
|
9
|
+
"commands": [
|
|
10
|
+
[
|
|
11
|
+
"node",
|
|
12
|
+
"--version"
|
|
13
|
+
]
|
|
14
|
+
],
|
|
15
|
+
"task_id": "01M5G3HVZPNN8ES63NJPV7MRN5",
|
|
16
|
+
"result": {
|
|
17
|
+
"outcome": "ok",
|
|
18
|
+
"status": "ok",
|
|
19
|
+
"stdout_bytes": 62238,
|
|
20
|
+
"blob_sha256": null,
|
|
21
|
+
"blob_sha256_verified": false,
|
|
22
|
+
"sections": null
|
|
23
|
+
},
|
|
24
|
+
"executed_at": "2026-10-21T20:51:34.767Z",
|
|
25
|
+
"executor": "lead",
|
|
26
|
+
"note": "relay v1.3.10-beta.5; agent v1.3.10-beta.5 (locally patched envelope fields); operator key pinned at bootstrap; result delivered as a blob because the diag output exceeds the inline cap"
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.10-beta.10",
|
|
3
|
+
"case": "M1-LIVE-POSTUPDATE-DIAG",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"requirement": "after an update to beta.10 and a restart, a new diag request still executes end to end",
|
|
6
|
+
"machines": [
|
|
7
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
8
|
+
],
|
|
9
|
+
"task_id": "01M5G4M6EJ2G62P1FBHDS1FE9F",
|
|
10
|
+
"result": {
|
|
11
|
+
"outcome": "ok",
|
|
12
|
+
"status": "ok",
|
|
13
|
+
"stdout_bytes": 59581,
|
|
14
|
+
"refusal": null
|
|
15
|
+
},
|
|
16
|
+
"executed_at": "2026-10-21T21:07:14.358Z",
|
|
17
|
+
"executor": "lead"
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.10-beta.10",
|
|
3
|
+
"case": "M1-LIVE-RESTART",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"requirement": "a signed restart request restarts the relay and the fleet result survives the restart",
|
|
6
|
+
"machines": [
|
|
7
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
8
|
+
],
|
|
9
|
+
"task_id": "01M5G3R8DJ2RNP0SNSC5J3WQWQ",
|
|
10
|
+
"result": {
|
|
11
|
+
"task": {
|
|
12
|
+
"outcome": "ok",
|
|
13
|
+
"status": "ok",
|
|
14
|
+
"refusal": null,
|
|
15
|
+
"stdout_bytes": 369
|
|
16
|
+
},
|
|
17
|
+
"relay_id_before": "68c2d7c3fb85bdc0",
|
|
18
|
+
"relay_id_after": "78446e30540b29b6",
|
|
19
|
+
"uptime_ms_after": 5710,
|
|
20
|
+
"operator_key_id": "79bcd7a23ad5b61f"
|
|
21
|
+
},
|
|
22
|
+
"executed_at": "2026-10-21T20:52:01.288Z",
|
|
23
|
+
"executor": "lead"
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.10-beta.10",
|
|
3
|
+
"case": "M1-LIVE-UPDATE",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"requirement": "a signed update request downloads, verifies and swaps the package on a remote machine",
|
|
6
|
+
"machines": [
|
|
7
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
8
|
+
],
|
|
9
|
+
"task_id": "01M5G4K9DY2K4BS0PDKVT555M1",
|
|
10
|
+
"result": {
|
|
11
|
+
"task": {
|
|
12
|
+
"outcome": "ok",
|
|
13
|
+
"status": "ok",
|
|
14
|
+
"refusal": null,
|
|
15
|
+
"stdout_bytes": 488,
|
|
16
|
+
"stdout_head": ""
|
|
17
|
+
},
|
|
18
|
+
"artifact_sha256": "b5a89ead5f0d94bc4a83d27f76ea2649c87a64af70f72cac3309fa13ecbdbb7c",
|
|
19
|
+
"restart_after_update": false,
|
|
20
|
+
"operator_key_id": "79bcd7a23ad5b61f"
|
|
21
|
+
},
|
|
22
|
+
"executed_at": "2026-10-21T21:06:44.656Z",
|
|
23
|
+
"executor": "lead"
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.3.11",
|
|
3
|
+
"case": "M1-LIVE-DIAG",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"requirement": "the stable 1.3.11 relay executes a signed maintenance diag request on a real machine and returns its result",
|
|
6
|
+
"machines": [
|
|
7
|
+
"1c14d7d2-d9e2-43dd-a7e5-eda98f4864bc"
|
|
8
|
+
],
|
|
9
|
+
"commands": [
|
|
10
|
+
[
|
|
11
|
+
"node",
|
|
12
|
+
"--version"
|
|
13
|
+
]
|
|
14
|
+
],
|
|
15
|
+
"task_id": "01M5GF2Z2EYKDJ39YYY9WJNK8C",
|
|
16
|
+
"result": {
|
|
17
|
+
"outcome": "ok",
|
|
18
|
+
"status": "ok",
|
|
19
|
+
"stdout_bytes": 60781,
|
|
20
|
+
"relay_id": "bcd9d427bfd5bbd9",
|
|
21
|
+
"deployed_version": "1.3.11"
|
|
22
|
+
},
|
|
23
|
+
"executed_at": "2026-10-22T00:10:03.977Z",
|
|
24
|
+
"executor": "lead",
|
|
25
|
+
"note": "relay deployed from the packed 1.3.11 candidate over SSH; operator key pinned at bootstrap; result delivered through the relay"
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.11",
|
|
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",
|
|
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,25 @@
|
|
|
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"
|
|
145
163
|
},
|
|
146
164
|
"devDependencies": {
|
|
147
165
|
"@eslint/js": "^10.0.1",
|
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.
|
|
4
|
+
"version": "1.3.11",
|
|
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,8 @@ 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 { ferryMaintenance, maintenancePaths } from './maintenance-ferry.mjs';
|
|
29
|
+
import { authorizeWithGrant } from './grant-gate.mjs';
|
|
28
30
|
import { tmpdir } from 'node:os';
|
|
29
31
|
import { randomUUID , sign as signBytes } from 'node:crypto';
|
|
30
32
|
|
|
@@ -2957,7 +2959,23 @@ export function createAgent(options) {
|
|
|
2957
2959
|
// `stages[0].command_argv` -- so this check covers the first stage and nothing else. Every
|
|
2958
2960
|
// stage is checked below, before any of them runs; see the note there for why the second check
|
|
2959
2961
|
// has to exist even though the plugin's pre-flight also checks every stage.
|
|
2960
|
-
|
|
2962
|
+
/**
|
|
2963
|
+
* v1.3.10-beta.3: the allow-list is a standing decision; a grant is a decision made now for exactly this argv.
|
|
2964
|
+
* Ask the grant gate before falling through to the allow-list refusal, and never the other way round.
|
|
2965
|
+
*/
|
|
2966
|
+
let allowed = matchAllowedCommand(commandArgv, allowedCommands);
|
|
2967
|
+
let grantAuthorized = false;
|
|
2968
|
+
if (allowed.allowed !== true && offer && typeof offer.grant === 'object' && offer.grant !== null) {
|
|
2969
|
+
const gatePaths = maintenancePaths();
|
|
2970
|
+
const gate = await authorizeWithGrant({
|
|
2971
|
+
offer,
|
|
2972
|
+
machineId: String(offer.machine_id ?? identity.machine_id ?? ''),
|
|
2973
|
+
pinFile: gatePaths.pinFile,
|
|
2974
|
+
usedFile: join(dirname(gatePaths.pinFile), 'grant-uses.json'),
|
|
2975
|
+
});
|
|
2976
|
+
if (gate.ok === true) { allowed = { allowed: true, ok: true, grant_id: gate.grantId }; grantAuthorized = true; }
|
|
2977
|
+
else allowed = { ok: false, reason_code: gate.code ?? 'GRANT_REJECTED', detail: gate.message };
|
|
2978
|
+
}
|
|
2961
2979
|
if (!allowed.allowed) {
|
|
2962
2980
|
log('warn', `refused ${offer.task_id}: ${allowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED}`, { argv: commandArgv });
|
|
2963
2981
|
await finish({
|
|
@@ -3036,6 +3054,49 @@ export function createAgent(options) {
|
|
|
3036
3054
|
* a reduce that ran on half the inputs would produce a confident wrong answer, which is the failure this feature exists to
|
|
3037
3055
|
* make impossible.
|
|
3038
3056
|
*/
|
|
3057
|
+
/**
|
|
3058
|
+
* v1.3.10-beta.2: a maintenance request is verified and carried by maintenance-ferry.mjs, not executed here. The
|
|
3059
|
+
* agent has no privilege to run update/restart/diag, which is the point of the separation.
|
|
3060
|
+
*/
|
|
3061
|
+
if (offer && typeof offer.maintenance === 'object' && offer.maintenance !== null) {
|
|
3062
|
+
const ferryStarted = Date.now();
|
|
3063
|
+
const ferriedRaw = await ferryMaintenance({ offer, machineId: String(offer.machine_id ?? identity.machine_id ?? '') });
|
|
3064
|
+
// The ferry successful only means the runner wrote a result file; the runner itself may have failed. Reporting that
|
|
3065
|
+
// as ok would tell the operator a restart/update happened when it did not.
|
|
3066
|
+
const runnerOk = ferriedRaw.ok === true && ferriedRaw.result?.status === 'ok';
|
|
3067
|
+
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 };
|
|
3068
|
+
const stdoutText = JSON.stringify(ferried).slice(0, 64 * 1024);
|
|
3069
|
+
const stderrText = ferried.ok ? '' : (ferried.message ?? ferried.code ?? 'maintenance failed');
|
|
3070
|
+
// The envelope builder hashes and sizes the output; leaving any of these undefined makes canonicalisation fail
|
|
3071
|
+
// with a JCS error and the task is reported as crashed after the maintenance action already ran.
|
|
3072
|
+
const execution = {
|
|
3073
|
+
exit_code: ferried.ok ? 0 : 1,
|
|
3074
|
+
stdout: stdoutText,
|
|
3075
|
+
stderr: stderrText,
|
|
3076
|
+
stdout_sha256: sha256Hex(stdoutText),
|
|
3077
|
+
stderr_sha256: sha256Hex(stderrText),
|
|
3078
|
+
stdout_bytes: Buffer.byteLength(stdoutText, 'utf8'),
|
|
3079
|
+
stderr_bytes: Buffer.byteLength(stderrText, 'utf8'),
|
|
3080
|
+
duration_ms: Date.now() - ferryStarted,
|
|
3081
|
+
timed_out: ferried.code === 'MAINTENANCE_RUNNER_TIMEOUT',
|
|
3082
|
+
};
|
|
3083
|
+
await finish({
|
|
3084
|
+
offer,
|
|
3085
|
+
cwdRel,
|
|
3086
|
+
commandArgv,
|
|
3087
|
+
anchorsBefore,
|
|
3088
|
+
anchorsAfter: null,
|
|
3089
|
+
execution,
|
|
3090
|
+
status: ferried.ok ? 'ok' : 'failed',
|
|
3091
|
+
refusalReason: ferried.ok ? null : 'MAINTENANCE_FAILED',
|
|
3092
|
+
warnings,
|
|
3093
|
+
startedAt,
|
|
3094
|
+
endedAt: new Date().toISOString(),
|
|
3095
|
+
protocolViolations: [],
|
|
3096
|
+
});
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3039
3100
|
let reduceEnv = null;
|
|
3040
3101
|
if (Array.isArray(offer.reduce_inputs) && offer.reduce_inputs.length > 0) {
|
|
3041
3102
|
try {
|
|
@@ -3081,7 +3142,8 @@ export function createAgent(options) {
|
|
|
3081
3142
|
}
|
|
3082
3143
|
|
|
3083
3144
|
for (const stage of plannedStages) {
|
|
3084
|
-
|
|
3145
|
+
// v1.3.10-beta.6: a grant that authorised step 5 covers the whole task, but only for the argv it signed.
|
|
3146
|
+
const stageAllowed = grantAuthorized ? { allowed: true, ok: true } : matchAllowedCommand(stage.command_argv, allowedCommands);
|
|
3085
3147
|
if (stageAllowed.allowed) continue;
|
|
3086
3148
|
const code = stageAllowed.reason_code ?? REFUSAL.COMMAND_NOT_ALLOWED;
|
|
3087
3149
|
log('warn', `refused ${offer.task_id}: ${code} at stage ${stage.index}`, {
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
writeUsed(usedFile, used);
|
|
62
|
+
return { ok: true, grantId: grant.grant_id, used: used.grants[grant.grant_id] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readUsed(file) {
|
|
66
|
+
if (typeof file !== 'string' || file === '' || !existsSync(file)) return { grants: {}, nonces: [] };
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
69
|
+
return { grants: parsed.grants && typeof parsed.grants === 'object' ? parsed.grants : {}, nonces: Array.isArray(parsed.nonces) ? parsed.nonces : [] };
|
|
70
|
+
} catch {
|
|
71
|
+
// A corrupt use ledger must not silently reset counts: that would make a spent grant usable again.
|
|
72
|
+
return { grants: { __corrupt__: 1 }, nonces: [] };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function writeUsed(file, used) {
|
|
77
|
+
if (typeof file !== 'string' || file === '') return;
|
|
78
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
79
|
+
const tmp = file + '.tmp-' + process.pid;
|
|
80
|
+
writeFileSync(tmp, JSON.stringify({ protocol: 'w2m-grant-uses/v1', ...used }, null, 2) + '\n', 'utf8');
|
|
81
|
+
renameSync(tmp, file);
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|