@twinsearth/w2m-dsh-plugin 1.9.15 → 1.9.17
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 +21 -0
- package/bin/w2m-joint.mjs +49 -5
- package/bin/w2m-scrub.mjs +78 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.16/RELEASE-1916.json +21 -0
- package/docs/acceptance/1.9.17/RELEASE-1917.json +20 -0
- package/docs/acceptance/2.0.0-beta.1/RELEASE-200B1.json +23 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-106.53.197.47.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-202.182.123.154.json +35 -0
- package/docs/acceptance-ops/2026-10-28/G0-3-43.142.74.71.json +35 -0
- package/docs/acceptance-ops/2026-10-28/R1-OPERATOR-TOKEN.json +19 -0
- package/docs/acceptance-ops/2026-10-28/R3-CAPABILITIES.json +51 -0
- package/docs/acceptance-ops/2026-10-28/R8-LEDGER-JOURNAL.json +21 -0
- package/lib/client.js +393 -367
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/plugin/joint-dev/plan-store.mjs +117 -32
- package/src/plugin/joint-dev/relay-dispatch.mjs +89 -0
- package/src/plugin/operator-key.mjs +73 -0
- package/src/plugin/operator-token-route.mjs +71 -0
- package/src/plugin/relay-capabilities.mjs +66 -0
- package/src/plugin/tools.mjs +6 -2
- package/src/relay/offer-journal.mjs +71 -0
- package/src/relay/p0-projection.mjs +10 -0
- package/src/relay/report.mjs +5 -2
- package/src/relay/scrub.mjs +94 -81
- package/src/relay/server.mjs +14 -1
- package/src/relay/state.mjs +3340 -3300
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.17",
|
|
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,7 +64,7 @@
|
|
|
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 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 test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.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 test/exec-capability.test.mjs test/secondary-link.test.mjs test/bootstrap-cli.test.mjs test/bootstrap.test.mjs test/windows-hidden-task.test.mjs test/relay-failover.test.mjs test/npm-update.test.mjs test/agent-start-nonblocking.test.mjs test/fleet-chat-capture.test.mjs test/capture-route.test.mjs test/update-resolver.test.mjs test/agent-identity-safe.test.mjs test/re-pair-stale-token.test.mjs test/gpw-folder-policy.test.mjs test/gpw-activation.test.mjs test/gpw-hardening.test.mjs test/gpw-tamper-gate.test.mjs test/gpw-persistence.test.mjs test/gpw-colors.test.mjs test/gpw-recent-fold.test.mjs test/gpw-auto-approve.test.mjs test/data-plane.test.mjs test/sealed-object.test.mjs test/joint-dev-dispatch.test.mjs test/joint-dev-loop.test.mjs test/joint-dev-audit.test.mjs test/joint-dev-redline.test.mjs test/relay-redline-guard.test.mjs test/relay-p0-projection.test.mjs test/guarded-dispatch.test.mjs test/p0-single-matcher.test.mjs test/path-policy.test.mjs test/path-policy-fs.test.mjs test/gpw-inheritance-table.test.mjs test/gpw-policy-adapter.test.mjs test/target-policy.test.mjs test/relay-redline-audit.test.mjs test/relay-repair-ledger.test.mjs test/relay-purge-blobs.test.mjs test/relay-scrub.test.mjs test/joint-dev-plan-store.test.mjs test/relay-basepath-blobs.test.mjs test/operator-token.test.mjs test/relay-health-capabilities.test.mjs test/joint-relay-dispatch.test.mjs test/operator-token-route.test.mjs test/relay-scrub-task-projection.test.mjs test/operator-key-approval.test.mjs test/relay-offer-journal.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",
|
|
@@ -208,7 +208,8 @@
|
|
|
208
208
|
"w2m-hidden-task": "bin/w2m-hidden-task.mjs",
|
|
209
209
|
"w2m-self-update": "bin/w2m-self-update.mjs",
|
|
210
210
|
"w2m-joint": "bin/w2m-joint.mjs",
|
|
211
|
-
"w2m-operator": "bin/w2m-operator.mjs"
|
|
211
|
+
"w2m-operator": "bin/w2m-operator.mjs",
|
|
212
|
+
"w2m-scrub": "bin/w2m-scrub.mjs"
|
|
212
213
|
},
|
|
213
214
|
"disclosure": {
|
|
214
215
|
"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.9.
|
|
4
|
+
"version": "1.9.17",
|
|
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": [
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* joint-dev 计划持久化与人工审批门(本地文件,0600)。
|
|
3
3
|
*
|
|
4
|
-
* 安全不变量(v2
|
|
4
|
+
* 安全不变量(v2):
|
|
5
5
|
* · plan_sha256 必须由本模块重算,与文件内容不符即 PLAN_TAMPERED;
|
|
6
6
|
* · planSha 必须是 64 位小写十六进制,禁止路径穿越;
|
|
7
|
-
* · 审批绑定 plan_sha256 + approver + authority +
|
|
8
|
-
* ·
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* · 审批绑定 plan_sha256 + approver + authority(70..100) + 有效期(60s..10min);
|
|
8
|
+
* · 审批由 operator Ed25519 私钥签名;签名覆盖除 sig/approval_sha256/operator_key 外的记录体,
|
|
9
|
+
* key_id 必须等于公钥指纹(防换公钥),可选 trustedOperatorKeyIds 做信任锚;
|
|
10
|
+
* · 审批写入原子(tmp→rename),dispatch 前置门可强制要求签名;
|
|
11
|
+
* · consumeApproval 用 O_EXCL 保证"一次审批只能派发一次"。
|
|
11
12
|
*/
|
|
12
13
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync,
|
|
14
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeSync } from 'node:fs';
|
|
14
15
|
import { join } from 'node:path';
|
|
15
16
|
|
|
17
|
+
import { keyIdOf, verifyApprovalPayload } from '../operator-key.mjs';
|
|
18
|
+
|
|
16
19
|
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
17
20
|
const PLAN_SHA = /^[0-9a-f]{64}$/;
|
|
18
|
-
const
|
|
21
|
+
const APPROVAL_TTL_MIN_MS = 60_000;
|
|
22
|
+
const APPROVAL_TTL_MAX_MS = 10 * 60 * 1000;
|
|
19
23
|
|
|
20
24
|
const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
|
|
21
25
|
const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
|
|
26
|
+
const consumedDir = (stateDir) => join(stateDir, 'joint-dev', 'consumed');
|
|
22
27
|
|
|
23
28
|
/** Hash of the plan body WITHOUT plan_sha256 — the same rule buildPlan uses. */
|
|
24
29
|
export function planHashOf(plan) {
|
|
@@ -42,6 +47,18 @@ function readPlanFile(stateDir, planSha) {
|
|
|
42
47
|
return { ok: true, plan, path, saved_at: parsed.saved_at ?? null };
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
function writeFileAtomic(path, text) {
|
|
51
|
+
const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
|
|
52
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
53
|
+
try {
|
|
54
|
+
writeSync(fd, text);
|
|
55
|
+
fsyncSync(fd);
|
|
56
|
+
} finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
renameSync(tmp, path);
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
export function savePlan({ stateDir, plan, now = Date.now() }) {
|
|
46
63
|
if (plan === null || typeof plan !== 'object' || !PLAN_SHA.test(String(plan.plan_sha256 ?? ''))) {
|
|
47
64
|
return { ok: false, code: 'PLAN_INVALID' };
|
|
@@ -53,10 +70,7 @@ export function savePlan({ stateDir, plan, now = Date.now() }) {
|
|
|
53
70
|
mkdirSync(planDir(stateDir), { recursive: true });
|
|
54
71
|
const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
|
|
55
72
|
if (existsSync(path)) return { ok: true, path, reused: true };
|
|
56
|
-
|
|
57
|
-
writeFileSync(tmp, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
58
|
-
// rename is atomic on the same volume; a crash leaves only the tmp file behind.
|
|
59
|
-
renameSync(tmp, path);
|
|
73
|
+
writeFileAtomic(path, JSON.stringify({ plan, saved_at: new Date(now).toISOString() }, null, 2));
|
|
60
74
|
return { ok: true, path, reused: false };
|
|
61
75
|
}
|
|
62
76
|
|
|
@@ -72,30 +86,38 @@ export function listPlans({ stateDir, limit = 50 }) {
|
|
|
72
86
|
const entries = [];
|
|
73
87
|
for (const name of readdirSync(dir)) {
|
|
74
88
|
if (!name.endsWith('.json')) continue;
|
|
75
|
-
const
|
|
89
|
+
const sha = name.slice(0, -5);
|
|
90
|
+
if (!PLAN_SHA.test(sha)) continue;
|
|
76
91
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
} catch { /* a vanished plan is simply absent */ }
|
|
92
|
+
entries.push({ plan_sha256: sha, saved_at_ms: statSync(join(dir, name)).mtimeMs });
|
|
93
|
+
} catch { /* absent */ }
|
|
80
94
|
}
|
|
81
95
|
entries.sort((a, b) => b.saved_at_ms - a.saved_at_ms);
|
|
82
96
|
return { ok: true, plans: entries.slice(0, Math.max(1, Math.min(200, limit))), total: entries.length };
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
/**
|
|
86
|
-
* 人工审批。authority
|
|
100
|
+
* 人工审批。authority 70..100;TTL 60s..10min(决策 2);传入 signer 时审批带 Ed25519 签名。
|
|
101
|
+
* signer 只被调用一次:签名覆盖最终要落盘的 body(不含 operator_key / sig / approval_sha256)。
|
|
102
|
+
*
|
|
103
|
+
* @param {{stateDir: string, planSha: string, approver: string, authority: number, note?: string, now?: number, ttlMs?: number,
|
|
104
|
+
* signer?: (body: object) => {sig: string, key_id: string, public_key_pem: string}}} input
|
|
87
105
|
*/
|
|
88
|
-
export function approvePlan(
|
|
106
|
+
export function approvePlan(input) {
|
|
107
|
+
const { stateDir, planSha, approver, authority, note = '', now = Date.now(), ttlMs = APPROVAL_TTL_MAX_MS, signer } = input;
|
|
89
108
|
if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
90
109
|
if (typeof approver !== 'string' || approver.trim() === '') return { ok: false, code: 'APPROVER_REQUIRED' };
|
|
91
|
-
if (!Number.isInteger(authority) || authority < 70
|
|
92
|
-
|
|
110
|
+
if (!Number.isInteger(authority) || authority < 70 || authority > 100) {
|
|
111
|
+
return { ok: false, code: 'APPROVAL_AUTHORITY_INVALID', detail: { authority } };
|
|
112
|
+
}
|
|
113
|
+
if (!Number.isInteger(ttlMs) || ttlMs < APPROVAL_TTL_MIN_MS || ttlMs > APPROVAL_TTL_MAX_MS) {
|
|
93
114
|
return { ok: false, code: 'APPROVAL_TTL_INVALID', detail: { ttlMs } };
|
|
94
115
|
}
|
|
95
116
|
const loaded = readPlanFile(stateDir, planSha);
|
|
96
117
|
if (loaded.ok !== true) return loaded;
|
|
97
118
|
mkdirSync(approvalDir(stateDir), { recursive: true });
|
|
98
|
-
|
|
119
|
+
|
|
120
|
+
const base = {
|
|
99
121
|
v: 2,
|
|
100
122
|
plan_sha256: planSha,
|
|
101
123
|
approver,
|
|
@@ -104,13 +126,29 @@ export function approvePlan({ stateDir, planSha, approver, authority, note = '',
|
|
|
104
126
|
approved_at: new Date(now).toISOString(),
|
|
105
127
|
expires_at: new Date(now + ttlMs).toISOString(),
|
|
106
128
|
};
|
|
107
|
-
|
|
129
|
+
let record = base;
|
|
130
|
+
if (typeof signer === 'function') {
|
|
131
|
+
const material = signer({ ...base });
|
|
132
|
+
if (typeof material?.sig !== 'string' || material.sig === '' || typeof material?.public_key_pem !== 'string') {
|
|
133
|
+
return { ok: false, code: 'APPROVAL_SIGN_FAILED' };
|
|
134
|
+
}
|
|
135
|
+
const keyId = String(material.key_id ?? '');
|
|
136
|
+
if (keyId === '' || keyId !== keyIdOf(material.public_key_pem)) {
|
|
137
|
+
return { ok: false, code: 'APPROVAL_KEY_MISMATCH' };
|
|
138
|
+
}
|
|
139
|
+
record = { ...base, operator_key: { key_id: keyId, public_key_pem: material.public_key_pem }, sig: material.sig };
|
|
140
|
+
}
|
|
141
|
+
const stored = { ...record, approval_sha256: sha256(JSON.stringify(record)) };
|
|
108
142
|
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
109
|
-
|
|
110
|
-
return { ok: true, approval:
|
|
143
|
+
writeFileAtomic(path, JSON.stringify(stored, null, 2));
|
|
144
|
+
return { ok: true, approval: stored, path, signed: typeof record.sig === 'string' };
|
|
111
145
|
}
|
|
112
146
|
|
|
113
|
-
|
|
147
|
+
/**
|
|
148
|
+
* @param {{stateDir: string, planSha: string, now?: number, trustedOperatorKeyIds?: string[]}} input
|
|
149
|
+
*/
|
|
150
|
+
export function loadApproval(input) {
|
|
151
|
+
const { stateDir, planSha, now = Date.now(), trustedOperatorKeyIds } = input;
|
|
114
152
|
if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
115
153
|
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
116
154
|
if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
@@ -119,19 +157,66 @@ export function loadApproval({ stateDir, planSha, now = Date.now() }) {
|
|
|
119
157
|
if (record?.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
|
|
120
158
|
const { approval_sha256: declared, ...body } = record;
|
|
121
159
|
if (sha256(JSON.stringify(body)) !== declared) return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
|
|
122
|
-
if (!Number.isInteger(record.authority) || record.authority < 70) {
|
|
123
|
-
return { ok: false, code: '
|
|
160
|
+
if (!Number.isInteger(record.authority) || record.authority < 70 || record.authority > 100) {
|
|
161
|
+
return { ok: false, code: 'APPROVAL_AUTHORITY_INVALID', detail: { authority: record.authority } };
|
|
124
162
|
}
|
|
163
|
+
const approved = Date.parse(record.approved_at ?? '');
|
|
125
164
|
const expires = Date.parse(record.expires_at ?? '');
|
|
126
|
-
if (Number.isFinite(
|
|
127
|
-
|
|
165
|
+
if (!Number.isFinite(approved) || !Number.isFinite(expires)) return { ok: false, code: 'APPROVAL_EXPIRY_INVALID' };
|
|
166
|
+
if (expires - approved < APPROVAL_TTL_MIN_MS || expires - approved > APPROVAL_TTL_MAX_MS) {
|
|
167
|
+
return { ok: false, code: 'APPROVAL_TTL_INVALID' };
|
|
168
|
+
}
|
|
169
|
+
if (now > expires) return { ok: false, code: 'APPROVAL_EXPIRED', detail: { expires_at: record.expires_at } };
|
|
170
|
+
let strength = 'digest';
|
|
171
|
+
if (typeof record.sig === 'string') {
|
|
172
|
+
const pem = record?.operator_key?.public_key_pem;
|
|
173
|
+
const keyId = record?.operator_key?.key_id;
|
|
174
|
+
if (typeof pem !== 'string' || pem === '' || typeof keyId !== 'string' || keyId === '') {
|
|
175
|
+
return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
|
|
176
|
+
}
|
|
177
|
+
if (keyId !== keyIdOf(pem)) return { ok: false, code: 'APPROVAL_KEY_MISMATCH' };
|
|
178
|
+
if (Array.isArray(trustedOperatorKeyIds) && !trustedOperatorKeyIds.includes(keyId)) {
|
|
179
|
+
return { ok: false, code: 'APPROVAL_KEY_UNTRUSTED', detail: { key_id: keyId } };
|
|
180
|
+
}
|
|
181
|
+
if (verifyApprovalPayload(record, record.sig, pem) !== true) {
|
|
182
|
+
return { ok: false, code: 'APPROVAL_SIGNATURE_INVALID' };
|
|
183
|
+
}
|
|
184
|
+
strength = 'signature';
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, approval: record, strength };
|
|
128
187
|
}
|
|
129
188
|
|
|
130
|
-
/**
|
|
131
|
-
|
|
189
|
+
/**
|
|
190
|
+
* 一次性消费:O_EXCL 写 consumed/<approval_sha>.json;第二次调用返回 APPROVAL_ALREADY_USED。
|
|
191
|
+
*/
|
|
192
|
+
export function consumeApproval({ stateDir, planSha, approvalSha, now = Date.now() }) {
|
|
193
|
+
if (!PLAN_SHA.test(String(planSha ?? '')) || !PLAN_SHA.test(String(approvalSha ?? ''))) {
|
|
194
|
+
return { ok: false, code: 'APPROVAL_SHA_INVALID' };
|
|
195
|
+
}
|
|
196
|
+
mkdirSync(consumedDir(stateDir), { recursive: true });
|
|
197
|
+
const path = join(consumedDir(stateDir), approvalSha + '.json');
|
|
198
|
+
try {
|
|
199
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
200
|
+
try {
|
|
201
|
+
writeSync(fd, JSON.stringify({ plan_sha256: planSha, approval_sha256: approvalSha, consumed_at: new Date(now).toISOString() }));
|
|
202
|
+
fsyncSync(fd);
|
|
203
|
+
} finally { closeSync(fd); }
|
|
204
|
+
return { ok: true, path };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error?.code === 'EEXIST') return { ok: false, code: 'APPROVAL_ALREADY_USED', detail: { path } };
|
|
207
|
+
return { ok: false, code: 'APPROVAL_CONSUME_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** dispatch 前置门:计划存在未篡改、审批有效且未过期;requireSignature 时必须是签名审批。 */
|
|
212
|
+
export function assertDispatchAllowed(input) {
|
|
213
|
+
const { stateDir, planSha, now = Date.now(), requireSignature = false, trustedOperatorKeyIds } = input;
|
|
132
214
|
const plan = loadPlan({ stateDir, planSha });
|
|
133
215
|
if (plan.ok !== true) return { ok: false, code: plan.code ?? 'PLAN_NOT_FOUND' };
|
|
134
|
-
const approval = loadApproval({ stateDir, planSha, now });
|
|
216
|
+
const approval = loadApproval({ stateDir, planSha, now, trustedOperatorKeyIds });
|
|
135
217
|
if (approval.ok !== true) return { ok: false, code: approval.code ?? 'APPROVAL_REQUIRED' };
|
|
136
|
-
|
|
218
|
+
if (requireSignature === true && approval.strength !== 'signature') {
|
|
219
|
+
return { ok: false, code: 'APPROVAL_SIGNATURE_REQUIRED' };
|
|
220
|
+
}
|
|
221
|
+
return { ok: true, plan: plan.plan, approval: approval.approval, strength: approval.strength };
|
|
137
222
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Joint-dev relay dispatch (v2 S2).
|
|
3
|
+
*
|
|
4
|
+
* Turns an approved plan into relay tasks: one step = one broadcast task pinned to the step's
|
|
5
|
+
* machine via `executor_machine_id`. Capabilities are negotiated first (an older relay that cannot
|
|
6
|
+
* honour the pin is refused, never assumed), and the leases the relay returns are checked: a lease
|
|
7
|
+
* on any machine other than the pinned one cancels the task and fails the dispatch.
|
|
8
|
+
*
|
|
9
|
+
* Only the control plane is written here: plan digest, step id, command hash and the argv the
|
|
10
|
+
* executor needs. Nothing is uploaded; artifacts are the separate sink path (S4).
|
|
11
|
+
*/
|
|
12
|
+
import { readRelayCapabilities, negotiateFeatures } from '../relay-capabilities.mjs';
|
|
13
|
+
|
|
14
|
+
const trimBase = (value) => (typeof value === 'string' ? value.replace(/\/+$/, '') : '');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{rabbitUrl: string, token: string, plan: object, approval: object, fetchImpl?: Function, timeoutMs?: number, includeOffline?: boolean}} input
|
|
18
|
+
*/
|
|
19
|
+
export async function dispatchPlanToRelay(input) {
|
|
20
|
+
const rabbitUrl = trimBase(input?.rabbitUrl);
|
|
21
|
+
const token = typeof input?.token === 'string' ? input.token : '';
|
|
22
|
+
const plan = input?.plan ?? null;
|
|
23
|
+
const approval = input?.approval ?? null;
|
|
24
|
+
if (rabbitUrl === '') return { ok: false, code: 'RELAY_URL_REQUIRED' };
|
|
25
|
+
if (token === '') return { ok: false, code: 'OPERATOR_TOKEN_MISSING' };
|
|
26
|
+
if (plan === null || typeof plan.plan_sha256 !== 'string') return { ok: false, code: 'PLAN_INVALID' };
|
|
27
|
+
if (approval === null || typeof approval.approval_sha256 !== 'string') return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
28
|
+
|
|
29
|
+
const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
|
|
30
|
+
const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 60_000;
|
|
31
|
+
const signalOf = () => (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
|
|
32
|
+
const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json', accept: 'application/json' };
|
|
33
|
+
|
|
34
|
+
const capabilities = await readRelayCapabilities({ rabbitUrl, fetchImpl, timeoutMs });
|
|
35
|
+
const negotiation = negotiateFeatures(capabilities);
|
|
36
|
+
if (negotiation.ok !== true) {
|
|
37
|
+
return { ok: false, code: negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', detail: { missing: negotiation.missing ?? null, relay: rabbitUrl } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tasks = [];
|
|
41
|
+
for (const step of plan.steps) {
|
|
42
|
+
const body = {
|
|
43
|
+
mode: 'broadcast',
|
|
44
|
+
executor_machine_id: step.machine_id,
|
|
45
|
+
command_argv: step.command_argv,
|
|
46
|
+
command_hash: step.command_hash,
|
|
47
|
+
base_commit: plan.base_commit,
|
|
48
|
+
plan_sha256: plan.plan_sha256,
|
|
49
|
+
approval_sha256: approval.approval_sha256,
|
|
50
|
+
step_id: step.step_id,
|
|
51
|
+
...(step.timeout_ms === undefined ? {} : { timeout_ms: step.timeout_ms }),
|
|
52
|
+
...(input?.includeOffline === false ? {} : { include_offline: true }),
|
|
53
|
+
};
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetchImpl(`${rabbitUrl}/v1/task`, { method: 'POST', headers, body: JSON.stringify(body), signal: signalOf() });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return { ok: false, code: 'RELAY_UNREACHABLE', detail: error instanceof Error ? error.message : String(error) };
|
|
59
|
+
}
|
|
60
|
+
const doc = await response.json().catch(() => null);
|
|
61
|
+
if (response.ok !== true) {
|
|
62
|
+
return { ok: false, code: doc?.error?.code ?? 'RELAY_REFUSED', status: response.status, detail: doc?.error ?? doc };
|
|
63
|
+
}
|
|
64
|
+
const taskId = doc?.task_id ?? doc?.task?.task_id ?? null;
|
|
65
|
+
const leases = Array.isArray(doc?.leases) ? doc.leases : [];
|
|
66
|
+
const foreign = leases.filter((lease) => lease?.state !== 'observing' && lease?.machine_id !== step.machine_id);
|
|
67
|
+
if (foreign.length > 0) {
|
|
68
|
+
if (taskId !== null) {
|
|
69
|
+
try {
|
|
70
|
+
await fetchImpl(`${rabbitUrl}/v1/tasks/${encodeURIComponent(taskId)}/cancel`, {
|
|
71
|
+
method: 'POST', headers, body: JSON.stringify({ reason: 'SELECTION_NOT_HONOURED' }), signal: signalOf(),
|
|
72
|
+
});
|
|
73
|
+
} catch { /* the refusal below is still the answer */ }
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
code: 'SELECTION_NOT_HONOURED',
|
|
78
|
+
detail: { step_id: step.step_id, named: step.machine_id, leased: foreign.map((lease) => lease.machine_id), task_id: taskId },
|
|
79
|
+
tasks,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
tasks.push({
|
|
83
|
+
step_id: step.step_id,
|
|
84
|
+
task_id: taskId,
|
|
85
|
+
leases: leases.map((lease) => ({ machine_id: lease?.machine_id ?? null, state: lease?.state ?? null })),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, relay_version: capabilities.relay_version ?? null, features: capabilities.features ?? null, tasks };
|
|
89
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Ed25519 key (v2 decisions 1 & 2).
|
|
3
|
+
*
|
|
4
|
+
* The key is generated once, stored as a 0600 PKCS#8 PEM under `~/.w2m/operator-key.pem`
|
|
5
|
+
* (`W2M_OPERATOR_KEY_FILE` overrides), and used to sign plan approvals. The public key travels
|
|
6
|
+
* with the approval; the private key never leaves this machine and is never logged.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export function operatorKeyPath(env = process.env) {
|
|
14
|
+
const explicit = typeof env.W2M_OPERATOR_KEY_FILE === 'string' ? env.W2M_OPERATOR_KEY_FILE.trim() : '';
|
|
15
|
+
if (explicit !== '') return explicit;
|
|
16
|
+
return join(homedir(), '.w2m', 'operator-key.pem');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function keyIdOf(publicKeyPem) {
|
|
20
|
+
return createHash('sha256').update(publicKeyPem, 'utf8').digest('hex').slice(0, 16);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The exact bytes that are signed: the approval body without its signature fields. */
|
|
24
|
+
export function approvalPayloadOf(body) {
|
|
25
|
+
const { sig, approval_sha256: _ignored, operator_key: _key, ...rest } = body ?? {};
|
|
26
|
+
return Buffer.from(JSON.stringify(rest), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function signApprovalPayload(body, privateKey) {
|
|
30
|
+
return sign(null, approvalPayloadOf(body), privateKey).toString('base64');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function verifyApprovalPayload(body, signatureBase64, publicKeyPem) {
|
|
34
|
+
try {
|
|
35
|
+
const key = createPublicKey(publicKeyPem);
|
|
36
|
+
return verify(null, approvalPayloadOf(body), key, Buffer.from(String(signatureBase64), 'base64'));
|
|
37
|
+
} catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Load the existing key, or create one and write it 0600. */
|
|
41
|
+
export function ensureOperatorKey(options = {}) {
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const path = options.path ?? operatorKeyPath(env);
|
|
44
|
+
if (existsSync(path)) {
|
|
45
|
+
const loaded = loadOperatorKey({ path });
|
|
46
|
+
return loaded.ok === true ? { ...loaded, created: false } : loaded;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
50
|
+
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' });
|
|
51
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
52
|
+
writeFileSync(path, pem, { encoding: 'utf8', mode: 0o600 });
|
|
53
|
+
try { chmodSync(path, 0o600); } catch { /* Windows ACL */ }
|
|
54
|
+
const loaded = loadOperatorKey({ path });
|
|
55
|
+
return loaded.ok === true ? { ...loaded, created: true } : loaded;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { ok: false, code: 'OPERATOR_KEY_CREATE_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadOperatorKey(options = {}) {
|
|
62
|
+
const env = options.env ?? process.env;
|
|
63
|
+
const path = options.path ?? operatorKeyPath(env);
|
|
64
|
+
if (!existsSync(path)) return { ok: false, code: 'OPERATOR_KEY_MISSING', path };
|
|
65
|
+
try {
|
|
66
|
+
const pem = readFileSync(path, 'utf8');
|
|
67
|
+
const privateKey = createPrivateKey(pem);
|
|
68
|
+
const publicKeyPem = createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
|
|
69
|
+
return { ok: true, path, privateKey, publicKeyPem, key_id: keyIdOf(publicKeyPem) };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, code: 'OPERATOR_KEY_UNREADABLE', path, detail: error instanceof Error ? error.message : String(error) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/w2m/operator-token` (v2 S0-1): the settings-page half of the secure token setup.
|
|
3
|
+
*
|
|
4
|
+
* Wire:
|
|
5
|
+
* GET /api/w2m/operator-token -> { ok, configured, source, fingerprint, path }
|
|
6
|
+
* POST /api/w2m/operator-token { token } -> { ok, configured: true, source: 'file', fingerprint, path }
|
|
7
|
+
*
|
|
8
|
+
* The token value is written straight into the 0600 file and never echoed back: the response carries
|
|
9
|
+
* only a fingerprint, so the page can confirm "configured" without the secret ever entering a log,
|
|
10
|
+
* a transcript or the DOM's value after the save.
|
|
11
|
+
*/
|
|
12
|
+
import { fingerprintOf, readOperatorToken, writeOperatorToken } from './operator-token.mjs';
|
|
13
|
+
|
|
14
|
+
export const OPERATOR_TOKEN_ROUTE_PATH = '/api/w2m/operator-token';
|
|
15
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} ctx Cordis context
|
|
19
|
+
* @param {{path?: () => string|null, configuredToken?: () => string|null}} [deps]
|
|
20
|
+
*/
|
|
21
|
+
export function registerOperatorTokenRoute(ctx, deps = {}) {
|
|
22
|
+
const status = { mounted: false, reason: 'the host has no Connection service' };
|
|
23
|
+
if (typeof ctx?.inject !== 'function') return status;
|
|
24
|
+
ctx.inject(['connection'], (webCtx) => {
|
|
25
|
+
const registry = webCtx?.connection?.fetch;
|
|
26
|
+
if (registry === undefined || typeof registry.register !== 'function') {
|
|
27
|
+
status.reason = 'the connection service carries no fetch registry';
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const pathOf = () => {
|
|
31
|
+
const configured = typeof deps?.path === 'function' ? deps.path() : null;
|
|
32
|
+
return typeof configured === 'string' && configured !== '' ? configured : undefined;
|
|
33
|
+
};
|
|
34
|
+
registry.register({
|
|
35
|
+
path: OPERATOR_TOKEN_ROUTE_PATH,
|
|
36
|
+
methods: ['GET', 'POST'],
|
|
37
|
+
requestBody: 'buffered',
|
|
38
|
+
fetch: async (request) => {
|
|
39
|
+
if (request.method === 'GET') {
|
|
40
|
+
const path = pathOf();
|
|
41
|
+
const read = path === undefined ? readOperatorToken() : readOperatorToken({ path });
|
|
42
|
+
const configuredToken = typeof deps?.configuredToken === 'function' ? deps.configuredToken() : null;
|
|
43
|
+
const fromFile = read.configured === true;
|
|
44
|
+
const source = fromFile ? 'file' : (typeof configuredToken === 'string' && configuredToken !== '' ? 'config-or-env' : null);
|
|
45
|
+
return Response.json({
|
|
46
|
+
ok: true,
|
|
47
|
+
configured: fromFile || source !== null,
|
|
48
|
+
source,
|
|
49
|
+
fingerprint: fromFile ? read.fingerprint : (source === null ? null : fingerprintOf(configuredToken)),
|
|
50
|
+
path: read.path ?? path ?? null,
|
|
51
|
+
}, { headers: NO_STORE });
|
|
52
|
+
}
|
|
53
|
+
let body = null;
|
|
54
|
+
try { body = await request.json(); } catch { /* refused below */ }
|
|
55
|
+
const token = typeof body?.token === 'string' ? body.token : '';
|
|
56
|
+
if (token.trim() === '') {
|
|
57
|
+
return Response.json({ ok: false, code: 'OPERATOR_TOKEN_REQUIRED' }, { status: 400, headers: NO_STORE });
|
|
58
|
+
}
|
|
59
|
+
const path = pathOf();
|
|
60
|
+
const written = path === undefined ? writeOperatorToken(token) : writeOperatorToken(token, { path });
|
|
61
|
+
if (written.ok !== true) {
|
|
62
|
+
return Response.json({ ok: false, code: written.code, detail: written.detail ?? null }, { status: 400, headers: NO_STORE });
|
|
63
|
+
}
|
|
64
|
+
return Response.json({ ok: true, configured: true, source: 'file', fingerprint: written.fingerprint, path: written.path }, { headers: NO_STORE });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
status.mounted = true;
|
|
68
|
+
status.reason = null;
|
|
69
|
+
});
|
|
70
|
+
return status;
|
|
71
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay capability negotiation (v2 G0-2).
|
|
3
|
+
*
|
|
4
|
+
* A joint-dev dispatch names machines on the wire and expects the relay to honour
|
|
5
|
+
* `target_machines`/`executor_machine_id`. An older relay silently ignores those fields, which
|
|
6
|
+
* widens an intended single-machine dispatch to the whole online fleet. The dispatcher therefore
|
|
7
|
+
* reads the relay's advertised features first and refuses when a required feature is missing.
|
|
8
|
+
*
|
|
9
|
+
* A relay with no `features` field at all is older than this protocol: callers decide policy
|
|
10
|
+
* (joint-dev refuses; ordinary dispatch keeps its post-dispatch lease check).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Features a joint-dev dispatch requires from the relay. */
|
|
14
|
+
export const JOINT_REQUIRED_FEATURES = Object.freeze(['target-machines', 'result-projection']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read `/healthz` and normalise the capability fields.
|
|
18
|
+
*
|
|
19
|
+
* @param {{rabbitUrl: string, fetchImpl?: Function, timeoutMs?: number}} input
|
|
20
|
+
* @returns {Promise<{ok: boolean, relay_version?: string|null, features?: string[]|null, protocol_version?: number|null, error?: string}>}
|
|
21
|
+
*/
|
|
22
|
+
export async function readRelayCapabilities(input) {
|
|
23
|
+
const base = typeof input?.rabbitUrl === 'string' ? input.rabbitUrl.replace(/\/$/, '') : '';
|
|
24
|
+
if (base === '') return { ok: false, error: 'rabbitUrl is required' };
|
|
25
|
+
const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
|
|
26
|
+
const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : 15_000;
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetchImpl(`${base}/healthz`, {
|
|
29
|
+
headers: { accept: 'application/json' },
|
|
30
|
+
signal: typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined,
|
|
31
|
+
});
|
|
32
|
+
if (response?.ok !== true) return { ok: false, error: `healthz answered HTTP ${response?.status ?? '?'}` };
|
|
33
|
+
const doc = await response.json();
|
|
34
|
+
const features = Array.isArray(doc?.features) ? doc.features.filter((entry) => typeof entry === 'string') : null;
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
relay_version: typeof doc?.relay_version === 'string' ? doc.relay_version : (typeof doc?.version === 'string' ? doc.version : null),
|
|
38
|
+
features,
|
|
39
|
+
protocol_version: Number.isInteger(doc?.protocol_version) ? doc.protocol_version : null,
|
|
40
|
+
operator_token_required: doc?.operator_token_required === true,
|
|
41
|
+
};
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decide whether the advertised capabilities satisfy a required set.
|
|
49
|
+
*
|
|
50
|
+
* @param {{features?: string[]|null}|null} capabilities
|
|
51
|
+
* @param {string[]} [required]
|
|
52
|
+
* @returns {{ok: boolean, code?: string, missing?: string[], unknown?: boolean}}
|
|
53
|
+
*/
|
|
54
|
+
export function negotiateFeatures(capabilities, required = JOINT_REQUIRED_FEATURES) {
|
|
55
|
+
if (capabilities === null || capabilities?.ok !== true) {
|
|
56
|
+
return { ok: false, code: 'RELAY_CAPABILITIES_UNAVAILABLE', unknown: true };
|
|
57
|
+
}
|
|
58
|
+
const features = capabilities.features;
|
|
59
|
+
if (!Array.isArray(features)) {
|
|
60
|
+
// No advertisement at all: an older relay. Callers that need the fields must not guess.
|
|
61
|
+
return { ok: false, code: 'RELAY_CAPABILITIES_MISSING', missing: [...required], unknown: true };
|
|
62
|
+
}
|
|
63
|
+
const missing = required.filter((feature) => !features.includes(feature));
|
|
64
|
+
if (missing.length > 0) return { ok: false, code: 'RELAY_CAPABILITIES_MISSING', missing, unknown: false };
|
|
65
|
+
return { ok: true };
|
|
66
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -191,6 +191,7 @@ import {
|
|
|
191
191
|
import { registerChatRoutes } from './fleet-chat-route.mjs';
|
|
192
192
|
import { registerExecCapabilityRoutes } from './exec-capability-route.mjs';
|
|
193
193
|
import { registerPluginUpdateRoutes } from './update-route.mjs';
|
|
194
|
+
import { registerOperatorTokenRoute } from './operator-token-route.mjs';
|
|
194
195
|
import { fingerprintOf, readOperatorToken } from './operator-token.mjs';
|
|
195
196
|
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
196
197
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
@@ -284,7 +285,7 @@ const MAX_STAGES = 16;
|
|
|
284
285
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
285
286
|
* overrides it at runtime for tests and packagers.
|
|
286
287
|
*/
|
|
287
|
-
const PLUGIN_VERSION = '1.9.
|
|
288
|
+
const PLUGIN_VERSION = '1.9.17';
|
|
288
289
|
|
|
289
290
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
290
291
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3583,6 +3584,7 @@ export async function apply(ctx, config = {}) {
|
|
|
3583
3584
|
// v1.6.8: the plugin panel's "更新" button: GET checks npm latest, POST installs through the
|
|
3584
3585
|
// verified updater (SHA-256 checked, old package backed up, restart required afterwards).
|
|
3585
3586
|
const _updateRoutes = registerPluginUpdateRoutes(ctx, { profileDir: () => cfg.profileDir });
|
|
3587
|
+
const _operatorTokenRoute = registerOperatorTokenRoute(ctx, { configuredToken: () => cfg.operatorToken });
|
|
3586
3588
|
|
|
3587
3589
|
// v1.7.1: host-side screenshot + clipboard fallbacks. The DSH desktop shell does not grant the
|
|
3588
3590
|
// renderer getDisplayMedia/navigator.clipboard, so the chat panel needs a host route that does not
|
|
@@ -4896,6 +4898,8 @@ const result = await request({
|
|
|
4896
4898
|
protocol_version: health.protocol_version ?? null,
|
|
4897
4899
|
error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
|
|
4898
4900
|
relay_id: health.relay_id ?? null,
|
|
4901
|
+
relay_version: health.relay_version ?? health.version ?? null,
|
|
4902
|
+
features: Array.isArray(health.features) ? health.features : null,
|
|
4899
4903
|
uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
|
|
4900
4904
|
started_at: health.started_at ?? null,
|
|
4901
4905
|
base_path: health.base_path ?? null,
|
|
@@ -4906,7 +4910,7 @@ const result = await request({
|
|
|
4906
4910
|
persistence: health.persistence ?? null,
|
|
4907
4911
|
// Which of the §4 fields this relay actually sent. A v1 relay sends none of them, and a
|
|
4908
4912
|
// reader should be able to tell "field absent" from "field false".
|
|
4909
|
-
diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required']
|
|
4913
|
+
diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required', 'relay_version', 'features']
|
|
4910
4914
|
.filter((key) => health[key] !== undefined && health[key] !== null),
|
|
4911
4915
|
};
|
|
4912
4916
|
} catch (error) {
|