@twinsearth/w2m-dsh-plugin 1.9.14 → 1.9.16
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 +12 -0
- package/bin/w2m-joint.mjs +13 -0
- package/bin/w2m-operator.mjs +73 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.15/RELEASE-1915.json +22 -0
- package/docs/acceptance/1.9.16/RELEASE-1916.json +21 -0
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/plugin/operator-token.mjs +83 -0
- package/src/plugin/relay-capabilities.mjs +66 -0
- package/src/plugin/tools.mjs +24 -7
- package/src/relay/server.mjs +14 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.9.
|
|
8
|
+
"version": "1.9.16"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.9.
|
|
15
|
+
"version": "1.9.16",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.9.
|
|
29
|
+
"version": "1.9.16"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.9.
|
|
4
|
+
"version": "1.9.16",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.9.16] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- G0-2:relay /healthz 新增 relay_version 与 features(target-machines / result-projection / guard-v1 / relay-version-v1);w2m_status 显示这两项,便于核对三台 relay 的实际版本与能力。
|
|
13
|
+
- 新增 relay-capabilities 模块与 w2m-joint dispatch 能力协商:目标 relay 缺少必需 feature(或旧 relay 完全不广告)时,联合派发 fail-closed 返回 RELAY_CAPABILITIES_MISSING,不靠“先发再发现”。
|
|
14
|
+
- 新增 test/relay-health-capabilities.test.mjs(5 项)。
|
|
15
|
+
|
|
16
|
+
## [1.9.15] - 2026-10-28
|
|
17
|
+
|
|
18
|
+
- G0-1:operator token 安全存储。新增 `~/.w2m/operator-token`(0600,可用 W2M_OPERATOR_TOKEN_FILE 覆盖)与 `w2m-operator` CLI(set 从 stdin/文件读取、status 只显示指纹、clear);w2m_status 新增 operatorToken_fingerprint/operatorToken_source,token 值不进日志/状态。
|
|
19
|
+
- 解析顺序:profile config(兼容)→ 安全文件(推荐)→ 环境变量(兼容)。
|
|
20
|
+
- 新增 test/operator-token.test.mjs(6 项)。
|
|
21
|
+
|
|
10
22
|
## [1.9.14] - 2026-10-28
|
|
11
23
|
|
|
12
24
|
- 修复「立即更新」失败(fetch failed):GitHub Release 下载被网络阻断时,自动回退到 npm registry 的同一版本 tarball;下载前校验 registry 的 sha512 integrity(缺失时 sha1 shasum),再由现有安装器做 sha256 校验、备份与回滚。
|
package/bin/w2m-joint.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { readFileSync } from 'node:fs';
|
|
21
21
|
|
|
22
22
|
import { buildControlEnvelopes, buildPlan } from '../src/plugin/joint-dev/dispatch.mjs';
|
|
23
|
+
import { negotiateFeatures, readRelayCapabilities } from '../src/plugin/relay-capabilities.mjs';
|
|
23
24
|
import {
|
|
24
25
|
approvePlan,
|
|
25
26
|
assertDispatchAllowed,
|
|
@@ -135,10 +136,22 @@ if (command === 'dispatch') {
|
|
|
135
136
|
if (args['dry-run'] !== true) {
|
|
136
137
|
fail('DISPATCH_NOT_WIRED', 'real dispatch is the next slice: it needs the operator token and a relay with target_machines/capability advertisement; use --dry-run to inspect the envelopes');
|
|
137
138
|
}
|
|
139
|
+
let capabilities = null;
|
|
140
|
+
let negotiation = null;
|
|
141
|
+
if (typeof args.relay === 'string') {
|
|
142
|
+
capabilities = await readRelayCapabilities({ rabbitUrl: args.relay });
|
|
143
|
+
negotiation = negotiateFeatures(capabilities);
|
|
144
|
+
if (negotiation.ok !== true) {
|
|
145
|
+
fail(negotiation.code ?? 'RELAY_CAPABILITIES_MISSING', { missing: negotiation.missing ?? null, relay: args.relay });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
138
148
|
const envelopes = buildControlEnvelopes(gate.plan);
|
|
139
149
|
print({
|
|
140
150
|
ok: true,
|
|
141
151
|
dry_run: true,
|
|
152
|
+
relay: typeof args.relay === 'string' ? args.relay : null,
|
|
153
|
+
relay_capabilities: capabilities === null ? null : { relay_version: capabilities.relay_version, features: capabilities.features },
|
|
154
|
+
relay_negotiation: negotiation,
|
|
142
155
|
plan_sha256: gate.plan.plan_sha256,
|
|
143
156
|
approval_sha256: gate.approval.approval_sha256,
|
|
144
157
|
envelopes: envelopes.length,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Operator token CLI (v2 G0-1).
|
|
4
|
+
*
|
|
5
|
+
* The token is read from stdin, never from argv, and stored in a 0600 file. Output is JSON with a
|
|
6
|
+
* fingerprint and the file path only — the value is never printed back.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* echo <token> | node bin/w2m-operator.mjs set
|
|
10
|
+
* node bin/w2m-operator.mjs set --file <path> (reads the file, still never echoes it)
|
|
11
|
+
* node bin/w2m-operator.mjs status
|
|
12
|
+
* node bin/w2m-operator.mjs clear
|
|
13
|
+
*
|
|
14
|
+
* Exit codes: 0 ok, 1 refusal, 2 usage.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
clearOperatorToken,
|
|
20
|
+
fingerprintOf,
|
|
21
|
+
operatorTokenPath,
|
|
22
|
+
readOperatorToken,
|
|
23
|
+
writeOperatorToken,
|
|
24
|
+
} from '../src/plugin/operator-token.mjs';
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const command = args[0];
|
|
28
|
+
const flag = (name) => {
|
|
29
|
+
const index = args.indexOf(name);
|
|
30
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
31
|
+
};
|
|
32
|
+
const print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
33
|
+
|
|
34
|
+
async function readStdin() {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
37
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (command === 'set') {
|
|
41
|
+
const file = flag('--file');
|
|
42
|
+
let token;
|
|
43
|
+
try { token = file !== undefined ? readFileSync(file, 'utf8') : await readStdin(); } catch (error) {
|
|
44
|
+
print({ ok: false, code: 'TOKEN_READ_FAILED', error: error instanceof Error ? error.message : String(error) });
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
const written = writeOperatorToken(token);
|
|
48
|
+
if (written.ok !== true) {
|
|
49
|
+
print({ ok: false, code: written.code, detail: written.detail ?? null });
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
print({ ok: true, path: written.path, fingerprint: written.fingerprint, source: 'file' });
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (command === 'status') {
|
|
57
|
+
const read = readOperatorToken();
|
|
58
|
+
if (read.configured !== true) {
|
|
59
|
+
print({ ok: true, configured: false, path: read.path, error: read.error ?? null });
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
print({ ok: true, configured: true, path: read.path, fingerprint: fingerprintOf(read.token) });
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (command === 'clear') {
|
|
67
|
+
const cleared = clearOperatorToken();
|
|
68
|
+
print({ ok: cleared.ok === true, path: cleared.path, code: cleared.code ?? null });
|
|
69
|
+
process.exit(cleared.ok === true ? 0 : 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
print({ ok: false, code: 'USAGE', path: operatorTokenPath(), usage: 'set [--file <path>] | status | clear' });
|
|
73
|
+
process.exit(2);
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.15",
|
|
3
|
+
"case": "RELEASE-1915",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T01:02:47.935Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1915-1793149367936",
|
|
11
|
+
"requirement": "V1.9.15:G0-1 operator token 安全存储与指纹状态",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"storage": "~/.w2m/operator-token 0600 or W2M_OPERATOR_TOKEN_FILE",
|
|
16
|
+
"cli": "set/status/clear, stdin only, fingerprint only",
|
|
17
|
+
"order": "config -> file -> env",
|
|
18
|
+
"status": "operatorToken_fingerprint/operatorToken_source, never value",
|
|
19
|
+
"tests": "operator-token 6/6"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.16",
|
|
3
|
+
"case": "RELEASE-1916",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T01:20:53.202Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1916-1793150453204",
|
|
11
|
+
"requirement": "V1.9.16:relay 版本/能力广告与联合派发能力协商 fail-closed",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"healthz": "relay_version + features[]",
|
|
16
|
+
"status": "w2m_status exposes relay_version/features",
|
|
17
|
+
"negotiation": "joint dispatch refuses RELAY_CAPABILITIES_MISSING on old relay or missing feature",
|
|
18
|
+
"tests": "relay-health-capabilities 5/5"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
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.16",
|
|
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",
|
|
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",
|
|
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",
|
|
@@ -207,7 +207,8 @@
|
|
|
207
207
|
"w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs",
|
|
208
208
|
"w2m-hidden-task": "bin/w2m-hidden-task.mjs",
|
|
209
209
|
"w2m-self-update": "bin/w2m-self-update.mjs",
|
|
210
|
-
"w2m-joint": "bin/w2m-joint.mjs"
|
|
210
|
+
"w2m-joint": "bin/w2m-joint.mjs",
|
|
211
|
+
"w2m-operator": "bin/w2m-operator.mjs"
|
|
211
212
|
},
|
|
212
213
|
"disclosure": {
|
|
213
214
|
"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.16",
|
|
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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure storage for the relay operator token (v2 G0-1).
|
|
3
|
+
*
|
|
4
|
+
* The token must not travel through chat, argv, logs or environment dumps. This module stores it in
|
|
5
|
+
* a single 0600 file under the per-user W2M directory (or W2M_OPERATOR_TOKEN_FILE), and every read
|
|
6
|
+
* returns metadata plus at most a short fingerprint — never the value in logs.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order used by the plugin: profile config (legacy) → this file → W2M_OPERATOR_TOKEN
|
|
9
|
+
* (legacy compatibility; discouraged). The file is the recommended source: it is not visible in the
|
|
10
|
+
* process environment and not echoed by any status output.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
const MIN_LENGTH = 16;
|
|
18
|
+
const MAX_LENGTH = 4096;
|
|
19
|
+
|
|
20
|
+
/** Where the token file lives. */
|
|
21
|
+
export function operatorTokenPath(env = process.env) {
|
|
22
|
+
const explicit = typeof env.W2M_OPERATOR_TOKEN_FILE === 'string' ? env.W2M_OPERATOR_TOKEN_FILE.trim() : '';
|
|
23
|
+
if (explicit !== '') return explicit;
|
|
24
|
+
return join(homedir(), '.w2m', 'operator-token');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A stable, non-reversible fingerprint for display ("configured, id 3f2a1c..."). */
|
|
28
|
+
export function fingerprintOf(token) {
|
|
29
|
+
if (typeof token !== 'string' || token === '') return null;
|
|
30
|
+
return createHash('sha256').update(token, 'utf8').digest('hex').slice(0, 12);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateOperatorToken(token) {
|
|
34
|
+
if (typeof token !== 'string') return { ok: false, code: 'OPERATOR_TOKEN_INVALID' };
|
|
35
|
+
const value = token.trim();
|
|
36
|
+
if (value.length < MIN_LENGTH) return { ok: false, code: 'OPERATOR_TOKEN_TOO_SHORT', detail: { min: MIN_LENGTH } };
|
|
37
|
+
if (value.length > MAX_LENGTH) return { ok: false, code: 'OPERATOR_TOKEN_TOO_LONG', detail: { max: MAX_LENGTH } };
|
|
38
|
+
if (/[\r\n]/.test(value)) return { ok: false, code: 'OPERATOR_TOKEN_INVALID', detail: 'token must be a single line' };
|
|
39
|
+
return { ok: true, token: value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the token file.
|
|
44
|
+
*
|
|
45
|
+
* @returns {{configured: false, path: string}
|
|
46
|
+
* | {configured: true, token: string, path: string, fingerprint: string, mode: number|null}}
|
|
47
|
+
*/
|
|
48
|
+
export function readOperatorToken(options = {}) {
|
|
49
|
+
const env = options.env ?? process.env;
|
|
50
|
+
const path = options.path ?? operatorTokenPath(env);
|
|
51
|
+
if (!existsSync(path)) return { configured: false, path };
|
|
52
|
+
let raw;
|
|
53
|
+
try { raw = readFileSync(path, 'utf8'); } catch (error) {
|
|
54
|
+
return { configured: false, path, error: error instanceof Error ? error.message : String(error) };
|
|
55
|
+
}
|
|
56
|
+
const checked = validateOperatorToken(raw);
|
|
57
|
+
if (checked.ok !== true) return { configured: false, path, error: checked.code };
|
|
58
|
+
return { configured: true, token: checked.token, path, fingerprint: fingerprintOf(checked.token) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function writeOperatorToken(token, options = {}) {
|
|
62
|
+
const checked = validateOperatorToken(token);
|
|
63
|
+
if (checked.ok !== true) return checked;
|
|
64
|
+
const env = options.env ?? process.env;
|
|
65
|
+
const path = options.path ?? operatorTokenPath(env);
|
|
66
|
+
try {
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
68
|
+
writeFileSync(path, checked.token + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
69
|
+
try { chmodSync(path, 0o600); } catch { /* Windows: the file ACL carries the protection */ }
|
|
70
|
+
} catch (error) {
|
|
71
|
+
return { ok: false, code: 'OPERATOR_TOKEN_WRITE_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, path, fingerprint: fingerprintOf(checked.token) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearOperatorToken(options = {}) {
|
|
77
|
+
const env = options.env ?? process.env;
|
|
78
|
+
const path = options.path ?? operatorTokenPath(env);
|
|
79
|
+
try { rmSync(path, { force: true }); } catch (error) {
|
|
80
|
+
return { ok: false, code: 'OPERATOR_TOKEN_CLEAR_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, path };
|
|
83
|
+
}
|
|
@@ -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 { fingerprintOf, readOperatorToken } from './operator-token.mjs';
|
|
194
195
|
import { registerCaptureRoutes } from './capture-route.mjs';
|
|
195
196
|
import { defaultExecCapabilityFile } from '../util/exec-capability.mjs';
|
|
196
197
|
import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
|
|
@@ -283,7 +284,7 @@ const MAX_STAGES = 16;
|
|
|
283
284
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
284
285
|
* overrides it at runtime for tests and packagers.
|
|
285
286
|
*/
|
|
286
|
-
const PLUGIN_VERSION = '1.9.
|
|
287
|
+
const PLUGIN_VERSION = '1.9.16';
|
|
287
288
|
|
|
288
289
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
289
290
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -687,6 +688,7 @@ async function loadDefineTool() {
|
|
|
687
688
|
* p2pMode: string, stunServers: string[]}}
|
|
688
689
|
*/
|
|
689
690
|
function readConfig(config = {}) {
|
|
691
|
+
let operatorTokenSource = null;
|
|
690
692
|
const cwd = process.cwd();
|
|
691
693
|
|
|
692
694
|
/**
|
|
@@ -872,11 +874,23 @@ function readConfig(config = {}) {
|
|
|
872
874
|
? process.env.W2M_PAIRING_CODE.trim()
|
|
873
875
|
: null),
|
|
874
876
|
// §5: the operator credential. Only `w2m_run` uses it.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
877
|
+
// v2 G0-1: the token file (0600) is the recommended source; profile config stays for legacy,
|
|
878
|
+
// env last. The value never reaches a status output - only the source and a fingerprint do.
|
|
879
|
+
operatorToken: (() => {
|
|
880
|
+
const fromConfig = typeof config.operatorToken === 'string' && config.operatorToken.trim() !== ''
|
|
881
|
+
? config.operatorToken.trim()
|
|
882
|
+
: null;
|
|
883
|
+
if (fromConfig !== null) { operatorTokenSource = 'config'; return fromConfig; }
|
|
884
|
+
const secure = readOperatorToken();
|
|
885
|
+
if (secure.configured === true) { operatorTokenSource = 'file'; return secure.token; }
|
|
886
|
+
const fromEnv = typeof process.env.W2M_OPERATOR_TOKEN === 'string' && process.env.W2M_OPERATOR_TOKEN.trim() !== ''
|
|
878
887
|
? process.env.W2M_OPERATOR_TOKEN.trim()
|
|
879
|
-
: null
|
|
888
|
+
: null;
|
|
889
|
+
if (fromEnv !== null) { operatorTokenSource = 'env'; return fromEnv; }
|
|
890
|
+
operatorTokenSource = null;
|
|
891
|
+
return null;
|
|
892
|
+
})(),
|
|
893
|
+
operatorTokenSource,
|
|
880
894
|
// ---------------------------------------------------------------------------------------------
|
|
881
895
|
// v0.2.3: daily self-update
|
|
882
896
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -4881,7 +4895,9 @@ const result = await request({
|
|
|
4881
4895
|
http_status: probe.status,
|
|
4882
4896
|
protocol_version: health.protocol_version ?? null,
|
|
4883
4897
|
error: probe.ok ? null : (probe.error?.code ?? `HTTP ${probe.status}`),
|
|
4884
|
-
relay_id: health.relay_id ?? null,
|
|
4898
|
+
relay_id: health.relay_id ?? null,
|
|
4899
|
+
relay_version: health.relay_version ?? health.version ?? null,
|
|
4900
|
+
features: Array.isArray(health.features) ? health.features : null,
|
|
4885
4901
|
uptime_ms: typeof health.uptime_ms === 'number' ? health.uptime_ms : null,
|
|
4886
4902
|
started_at: health.started_at ?? null,
|
|
4887
4903
|
base_path: health.base_path ?? null,
|
|
@@ -4892,7 +4908,7 @@ const result = await request({
|
|
|
4892
4908
|
persistence: health.persistence ?? null,
|
|
4893
4909
|
// Which of the §4 fields this relay actually sent. A v1 relay sends none of them, and a
|
|
4894
4910
|
// reader should be able to tell "field absent" from "field false".
|
|
4895
|
-
diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required']
|
|
4911
|
+
diagnostics_present: ['relay_id', 'uptime_ms', 'base_path', 'effective_scheme', 'operator_token_required', 'relay_version', 'features']
|
|
4896
4912
|
.filter((key) => health[key] !== undefined && health[key] !== null),
|
|
4897
4913
|
};
|
|
4898
4914
|
} catch (error) {
|
|
@@ -4996,6 +5012,7 @@ const result = await request({
|
|
|
4996
5012
|
// wrong one without spending a dispatch, and `required_by_relay` is what makes the two
|
|
4997
5013
|
// halves of the mismatch (client has none / relay demands one) visible side by side.
|
|
4998
5014
|
operatorToken_configured: cfg.operatorToken !== null,
|
|
5015
|
+
operatorToken_fingerprint: cfg.operatorToken === null ? null : fingerprintOf(cfg.operatorToken),
|
|
4999
5016
|
operatorToken_source: cfg.operatorToken !== null
|
|
5000
5017
|
? (typeof config?.operatorToken === 'string' && config.operatorToken.trim() !== ''
|
|
5001
5018
|
? 'config'
|
package/src/relay/server.mjs
CHANGED
|
@@ -22,6 +22,17 @@ function readRelayVersion() {
|
|
|
22
22
|
return process.env.W2M_RELAY_VERSION ?? 'unknown';
|
|
23
23
|
}
|
|
24
24
|
export const RELAY_VERSION = readRelayVersion();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Capabilities a dispatcher may rely on. A relay that does not advertise a feature is an older
|
|
28
|
+
* relay: joint-dev must refuse rather than assume the field will be honoured.
|
|
29
|
+
*/
|
|
30
|
+
export const RELAY_FEATURES = Object.freeze([
|
|
31
|
+
'target-machines',
|
|
32
|
+
'result-projection',
|
|
33
|
+
'guard-v1',
|
|
34
|
+
'relay-version-v1',
|
|
35
|
+
]);
|
|
25
36
|
import fs from 'node:fs';
|
|
26
37
|
import os from 'node:os';
|
|
27
38
|
import path from 'node:path';
|
|
@@ -1591,7 +1602,9 @@ export class RelayServer {
|
|
|
1591
1602
|
rabbit_time: this.state.nowIso(),
|
|
1592
1603
|
uptime_ms: this.state.nowMs() - this._startedAtMs,
|
|
1593
1604
|
relay_id: this.relayId,
|
|
1594
|
-
|
|
1605
|
+
version: RELAY_VERSION,
|
|
1606
|
+
relay_version: RELAY_VERSION,
|
|
1607
|
+
features: RELAY_FEATURES,
|
|
1595
1608
|
started_at: rfc3339(this._startedAtMs),
|
|
1596
1609
|
effective_scheme: this.effectiveScheme(req),
|
|
1597
1610
|
base_path: this.basePath,
|