@twinsearth/w2m-dsh-plugin 1.9.11 → 1.9.14
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 +18 -0
- package/bin/w2m-joint.mjs +151 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.12/RELEASE-1912.json +20 -0
- package/docs/acceptance/1.9.13/RELEASE-1913.json +20 -0
- package/docs/acceptance/1.9.14/RELEASE-1914.json +20 -0
- package/package.json +4 -3
- package/plugin.json +1 -1
- package/src/plugin/joint-dev/plan-store.mjs +92 -21
- package/src/plugin/npm-update.mjs +62 -0
- package/src/plugin/tools.mjs +52 -3
- package/src/plugin/update-route.mjs +31 -0
- package/src/relay/p0-projection.mjs +26 -4
- package/src/relay/purge-blobs.mjs +137 -0
- package/src/relay/repair-ledger.mjs +171 -0
- package/src/relay/scrub.mjs +7 -17
- package/src/relay/server.mjs +19 -148
|
@@ -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.14"
|
|
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.14",
|
|
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.14"
|
|
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.14",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.9.14] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
- 修复「立即更新」失败(fetch failed):GitHub Release 下载被网络阻断时,自动回退到 npm registry 的同一版本 tarball;下载前校验 registry 的 sha512 integrity(缺失时 sha1 shasum),再由现有安装器做 sha256 校验、备份与回滚。
|
|
13
|
+
- 安装器仍走运行时 pnpm(Windows 不再尝试 dsh.cmd)。
|
|
14
|
+
- 更新相关测试 66/66 通过。
|
|
15
|
+
|
|
16
|
+
## [1.9.13] - 2026-10-28
|
|
17
|
+
|
|
18
|
+
- 新增操作员联合开发 CLI `w2m-joint`:plan create/list/get、approve(人签、authority≥70、TTL 默认 10 分钟)、status、dispatch --dry-run(打印控制信封,真实发送未接线并返回 DISPATCH_NOT_WIRED)。
|
|
19
|
+
- 该 CLI 是 SOP 的操作员入口:计划先落盘、人工审批、再允许派发;dispatch 真发仍要求 operator token 与 relay 能力广告(下一片)。
|
|
20
|
+
- 端到端本机冒烟:create → approve → status(dispatch_allowed=true) → dispatch --dry-run(2 envelopes)。
|
|
21
|
+
|
|
22
|
+
## [1.9.12] - 2026-10-28
|
|
23
|
+
|
|
24
|
+
- 联合开发安全门(S1/S2 预置):plan-store 保存与读取都重算 plan_sha256(篡改即 PLAN_TAMPERED)、planSha 强制 64 位十六进制(拒绝路径穿越)、审批绑定 plan_sha/approver/authority/TTL 并重算 approval_sha256(篡改/过期/低权限拒绝)、原子写。
|
|
25
|
+
- 全部机器选中真实性(S1):w2m_run 在派发前用当前设备名册重校验被选机器(离线即 TARGET_UNAVAILABLE,不换机);派发后按 relay 的 leases 校验,发现未被点名的机器也拿到租约即取消任务并返回 SELECTION_NOT_HONOURED。
|
|
26
|
+
- 新增 test/joint-dev-plan-store.test.mjs(11 项)。
|
|
27
|
+
|
|
10
28
|
## [1.9.11] - 2026-10-28
|
|
11
29
|
|
|
12
30
|
- 修复左侧栏只剩图标:GPW 名称恢复到入口内(🌐 GPW),侧栏 label 仍为空,避免再次重复。
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Operator CLI for joint-dev plans (v2 S2 slice).
|
|
4
|
+
*
|
|
5
|
+
* This is the operator side of the SOP, not an agent tool: a plan is created here, approved here by
|
|
6
|
+
* a human, and only then may a dispatch run. It never talks to a relay in this slice; `dispatch
|
|
7
|
+
* --dry-run` prints the control envelopes that would be sent. Real dispatch needs the operator token
|
|
8
|
+
* and the relay capability check, which land in the next slice.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node bin/w2m-joint.mjs plan create --state <dir> --steps <steps.json> [--base-commit <sha>] [--title <text>]
|
|
12
|
+
* node bin/w2m-joint.mjs plan list --state <dir>
|
|
13
|
+
* node bin/w2m-joint.mjs plan get --state <dir> --plan <sha>
|
|
14
|
+
* node bin/w2m-joint.mjs approve --state <dir> --plan <sha> --approver <name> --authority <n> [--ttl-min <n>] [--note <text>]
|
|
15
|
+
* node bin/w2m-joint.mjs status --state <dir> --plan <sha>
|
|
16
|
+
* node bin/w2m-joint.mjs dispatch --state <dir> --plan <sha> --dry-run
|
|
17
|
+
*
|
|
18
|
+
* Exit codes: 0 ok, 1 refusal (named code), 2 usage error.
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
import { buildControlEnvelopes, buildPlan } from '../src/plugin/joint-dev/dispatch.mjs';
|
|
23
|
+
import {
|
|
24
|
+
approvePlan,
|
|
25
|
+
assertDispatchAllowed,
|
|
26
|
+
listPlans,
|
|
27
|
+
loadApproval,
|
|
28
|
+
loadPlan,
|
|
29
|
+
savePlan,
|
|
30
|
+
} from '../src/plugin/joint-dev/plan-store.mjs';
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const out = { _: [] };
|
|
34
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg.startsWith('--')) {
|
|
37
|
+
const key = arg.slice(2);
|
|
38
|
+
const next = argv[i + 1];
|
|
39
|
+
if (next !== undefined && !next.startsWith('--')) { out[key] = next; i += 1; }
|
|
40
|
+
else out[key] = true;
|
|
41
|
+
} else out._.push(arg);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const print = (value) => process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
|
47
|
+
const fail = (code, detail) => { print({ ok: false, code, detail: detail ?? null }); process.exit(1); };
|
|
48
|
+
|
|
49
|
+
const args = parseArgs(process.argv.slice(2));
|
|
50
|
+
const [command, action] = args._;
|
|
51
|
+
const stateDir = typeof args.state === 'string' ? args.state : (process.env.W2M_JOINT_STATE ?? null);
|
|
52
|
+
if (stateDir === null) fail('STATE_DIR_REQUIRED', 'pass --state <dir> (the joint-dev state directory)');
|
|
53
|
+
|
|
54
|
+
if (command === 'plan' && action === 'create') {
|
|
55
|
+
if (typeof args.steps !== 'string') fail('STEPS_REQUIRED', 'pass --steps <steps.json>');
|
|
56
|
+
let steps;
|
|
57
|
+
try { steps = JSON.parse(readFileSync(args.steps, 'utf8').replace(/^\\uFEFF/, '')); } catch (error) {
|
|
58
|
+
fail('STEPS_UNREADABLE', error instanceof Error ? error.message : String(error));
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(steps)) fail('STEPS_INVALID', 'steps.json must be a JSON array');
|
|
61
|
+
const built = buildPlan({
|
|
62
|
+
base_commit: typeof args['base-commit'] === 'string' ? args['base-commit'] : undefined,
|
|
63
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
64
|
+
steps,
|
|
65
|
+
});
|
|
66
|
+
if (built.ok !== true) fail(built.code ?? 'PLAN_INVALID', built.detail ?? null);
|
|
67
|
+
const saved = savePlan({ stateDir, plan: built.plan });
|
|
68
|
+
if (saved.ok !== true) fail(saved.code ?? 'PLAN_SAVE_FAILED', saved.detail ?? null);
|
|
69
|
+
print({
|
|
70
|
+
ok: true,
|
|
71
|
+
plan_sha256: built.plan.plan_sha256,
|
|
72
|
+
steps: built.plan.steps.length,
|
|
73
|
+
machines: [...new Set(built.plan.steps.map((step) => step.machine_id))],
|
|
74
|
+
path: saved.path,
|
|
75
|
+
reused: saved.reused,
|
|
76
|
+
});
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (command === 'plan' && action === 'list') {
|
|
81
|
+
const listed = listPlans({ stateDir, limit: Number(args.limit) || 50 });
|
|
82
|
+
if (listed.ok !== true) fail(listed.code ?? 'PLAN_LIST_FAILED');
|
|
83
|
+
print({ ok: true, total: listed.total, plans: listed.plans });
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (command === 'plan' && action === 'get') {
|
|
88
|
+
if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
|
|
89
|
+
const loaded = loadPlan({ stateDir, planSha: args.plan });
|
|
90
|
+
if (loaded.ok !== true) fail(loaded.code ?? 'PLAN_NOT_FOUND', loaded.detail ?? null);
|
|
91
|
+
print({ ok: true, plan: loaded.plan, saved_at: loaded.saved_at });
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (command === 'approve') {
|
|
96
|
+
if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
|
|
97
|
+
if (typeof args.approver !== 'string') fail('APPROVER_REQUIRED', 'pass --approver <name>');
|
|
98
|
+
const authority = Number.parseInt(String(args.authority ?? ''), 10);
|
|
99
|
+
if (!Number.isInteger(authority)) fail('AUTHORITY_REQUIRED', 'pass --authority <0..100>');
|
|
100
|
+
const ttlMin = Number.parseInt(String(args['ttl-min'] ?? '10'), 10);
|
|
101
|
+
const approved = approvePlan({
|
|
102
|
+
stateDir,
|
|
103
|
+
planSha: args.plan,
|
|
104
|
+
approver: args.approver,
|
|
105
|
+
authority,
|
|
106
|
+
note: typeof args.note === 'string' ? args.note : '',
|
|
107
|
+
ttlMs: (Number.isFinite(ttlMin) ? ttlMin : 10) * 60_000,
|
|
108
|
+
});
|
|
109
|
+
if (approved.ok !== true) fail(approved.code ?? 'APPROVAL_FAILED', approved.detail ?? null);
|
|
110
|
+
print({ ok: true, approval: approved.approval, path: approved.path });
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (command === 'status') {
|
|
115
|
+
if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
|
|
116
|
+
const plan = loadPlan({ stateDir, planSha: args.plan });
|
|
117
|
+
if (plan.ok !== true) fail(plan.code ?? 'PLAN_NOT_FOUND');
|
|
118
|
+
const approval = loadApproval({ stateDir, planSha: args.plan });
|
|
119
|
+
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan });
|
|
120
|
+
print({
|
|
121
|
+
ok: true,
|
|
122
|
+
plan_sha256: plan.plan.plan_sha256,
|
|
123
|
+
steps: plan.plan.steps.map((step) => ({ step_id: step.step_id, machine_id: step.machine_id, command_hash: step.command_hash })),
|
|
124
|
+
approval: approval.ok ? { approver: approval.approval.approver, authority: approval.approval.authority, expires_at: approval.approval.expires_at } : { state: approval.code },
|
|
125
|
+
dispatch_allowed: gate.ok === true,
|
|
126
|
+
blocked_by: gate.ok === true ? null : gate.code,
|
|
127
|
+
});
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (command === 'dispatch') {
|
|
132
|
+
if (typeof args.plan !== 'string') fail('PLAN_REQUIRED', 'pass --plan <sha256>');
|
|
133
|
+
const gate = assertDispatchAllowed({ stateDir, planSha: args.plan });
|
|
134
|
+
if (gate.ok !== true) fail(gate.code ?? 'APPROVAL_REQUIRED', 'dispatch refused before anything was sent');
|
|
135
|
+
if (args['dry-run'] !== true) {
|
|
136
|
+
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
|
+
const envelopes = buildControlEnvelopes(gate.plan);
|
|
139
|
+
print({
|
|
140
|
+
ok: true,
|
|
141
|
+
dry_run: true,
|
|
142
|
+
plan_sha256: gate.plan.plan_sha256,
|
|
143
|
+
approval_sha256: gate.approval.approval_sha256,
|
|
144
|
+
envelopes: envelopes.length,
|
|
145
|
+
control: envelopes.map((envelope) => ({ type: envelope.type, body: envelope.body })),
|
|
146
|
+
});
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.stderr.write('usage: node bin/w2m-joint.mjs <plan create|list|get|approve|status|dispatch> [options]\n');
|
|
151
|
+
process.exit(2);
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.12",
|
|
3
|
+
"case": "RELEASE-1912",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T00:07:11.551Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1912-1793146031552",
|
|
11
|
+
"requirement": "V1.9.12:联合开发安全门(plan/approval)与选中真实性(派发前重校验 + 派发后租约校验)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"plan_store": "plan hash recompute, traversal refusal, approval signature/TTL, atomic write; 11 tests",
|
|
16
|
+
"selection": "pre-dispatch roster revalidation (TARGET_UNAVAILABLE) and post-dispatch foreign-lease refusal (SELECTION_NOT_HONOURED)",
|
|
17
|
+
"tests": "tools/broadcast/fleet-target 209/209 green"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.13",
|
|
3
|
+
"case": "RELEASE-1913",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T00:31:27.437Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1913-1793147487438",
|
|
11
|
+
"requirement": "V1.9.13:操作员联合开发 CLI(计划/审批/状态/派发 dry-run)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"cli": "w2m-joint plan create/list/get, approve, status, dispatch --dry-run",
|
|
16
|
+
"smoke": "create -> approve(authority 80, TTL 10m) -> status dispatch_allowed=true -> dry-run 2 envelopes",
|
|
17
|
+
"limits": "real dispatch returns DISPATCH_NOT_WIRED until operator token + relay capability slice"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.14",
|
|
3
|
+
"case": "RELEASE-1914",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-28T00:46:07.739Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-1914-1793148367740",
|
|
11
|
+
"requirement": "V1.9.14:立即更新 fetch failed 的 npm 回退",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"npm_fallback": "fetchNpmTarball verifies registry integrity then installs",
|
|
16
|
+
"route": "update-route falls back when GitHub install is not installed",
|
|
17
|
+
"tests": "npm-update/update-wiring/update-install 66/66"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
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.14",
|
|
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",
|
|
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",
|
|
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",
|
|
@@ -206,7 +206,8 @@
|
|
|
206
206
|
"w2m-bootstrap": "bin/w2m-bootstrap.mjs",
|
|
207
207
|
"w2m-bootstrap-sign": "bin/w2m-bootstrap-sign.mjs",
|
|
208
208
|
"w2m-hidden-task": "bin/w2m-hidden-task.mjs",
|
|
209
|
-
"w2m-self-update": "bin/w2m-self-update.mjs"
|
|
209
|
+
"w2m-self-update": "bin/w2m-self-update.mjs",
|
|
210
|
+
"w2m-joint": "bin/w2m-joint.mjs"
|
|
210
211
|
},
|
|
211
212
|
"disclosure": {
|
|
212
213
|
"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.14",
|
|
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,46 +1,108 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* joint-dev 计划持久化与人工审批门(本地文件,0600)。
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* 安全不变量(v2 预置):
|
|
5
|
+
* · plan_sha256 必须由本模块重算,与文件内容不符即 PLAN_TAMPERED;
|
|
6
|
+
* · planSha 必须是 64 位小写十六进制,禁止路径穿越;
|
|
7
|
+
* · 审批绑定 plan_sha256 + approver + authority + 过期时间,approval_sha256 读回时重算;
|
|
8
|
+
* · 未审批的计划禁止 dispatch(assertDispatchAllowed 是唯一入口)。
|
|
9
|
+
*
|
|
10
|
+
* 审批只由操作员 CLI/UI 写入,不暴露为 agent 工具。
|
|
4
11
|
*/
|
|
5
12
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
14
|
import { join } from 'node:path';
|
|
8
15
|
|
|
9
16
|
const sha256 = (text) => createHash('sha256').update(text, 'utf8').digest('hex');
|
|
17
|
+
const PLAN_SHA = /^[0-9a-f]{64}$/;
|
|
18
|
+
const DEFAULT_APPROVAL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
|
|
10
20
|
const planDir = (stateDir) => join(stateDir, 'joint-dev', 'plans');
|
|
11
21
|
const approvalDir = (stateDir) => join(stateDir, 'joint-dev', 'approvals');
|
|
12
22
|
|
|
23
|
+
/** Hash of the plan body WITHOUT plan_sha256 — the same rule buildPlan uses. */
|
|
24
|
+
export function planHashOf(plan) {
|
|
25
|
+
if (plan === null || typeof plan !== 'object') return null;
|
|
26
|
+
const { plan_sha256: _ignored, ...canonical } = plan;
|
|
27
|
+
return sha256(JSON.stringify(canonical));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readPlanFile(stateDir, planSha) {
|
|
31
|
+
if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
32
|
+
const path = join(planDir(stateDir), planSha + '.json');
|
|
33
|
+
if (!existsSync(path)) return { ok: false, code: 'PLAN_NOT_FOUND' };
|
|
34
|
+
let parsed;
|
|
35
|
+
try { parsed = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'PLAN_UNREADABLE' }; }
|
|
36
|
+
const plan = parsed?.plan;
|
|
37
|
+
if (plan === null || typeof plan !== 'object') return { ok: false, code: 'PLAN_INVALID' };
|
|
38
|
+
const recomputed = planHashOf(plan);
|
|
39
|
+
if (recomputed !== plan.plan_sha256 || recomputed !== planSha) {
|
|
40
|
+
return { ok: false, code: 'PLAN_TAMPERED', detail: { declared: plan.plan_sha256, recomputed, path } };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, plan, path, saved_at: parsed.saved_at ?? null };
|
|
43
|
+
}
|
|
44
|
+
|
|
13
45
|
export function savePlan({ stateDir, plan, now = Date.now() }) {
|
|
14
|
-
if (typeof plan
|
|
46
|
+
if (plan === null || typeof plan !== 'object' || !PLAN_SHA.test(String(plan.plan_sha256 ?? ''))) {
|
|
47
|
+
return { ok: false, code: 'PLAN_INVALID' };
|
|
48
|
+
}
|
|
49
|
+
const recomputed = planHashOf(plan);
|
|
50
|
+
if (recomputed !== plan.plan_sha256) {
|
|
51
|
+
return { ok: false, code: 'PLAN_HASH_MISMATCH', detail: { declared: plan.plan_sha256, recomputed } };
|
|
52
|
+
}
|
|
15
53
|
mkdirSync(planDir(stateDir), { recursive: true });
|
|
16
54
|
const path = join(planDir(stateDir), plan.plan_sha256 + '.json');
|
|
17
55
|
if (existsSync(path)) return { ok: true, path, reused: true };
|
|
18
|
-
|
|
56
|
+
const tmp = path + '.tmp-' + process.pid;
|
|
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);
|
|
19
60
|
return { ok: true, path, reused: false };
|
|
20
61
|
}
|
|
21
62
|
|
|
22
63
|
export function loadPlan({ stateDir, planSha }) {
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
25
|
-
return { ok: true,
|
|
64
|
+
const loaded = readPlanFile(stateDir, planSha);
|
|
65
|
+
if (loaded.ok !== true) return loaded;
|
|
66
|
+
return { ok: true, plan: loaded.plan, saved_at: loaded.saved_at, path: loaded.path };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function listPlans({ stateDir, limit = 50 }) {
|
|
70
|
+
const dir = planDir(stateDir);
|
|
71
|
+
if (!existsSync(dir)) return { ok: true, plans: [], total: 0 };
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const name of readdirSync(dir)) {
|
|
74
|
+
if (!name.endsWith('.json')) continue;
|
|
75
|
+
const path = join(dir, name);
|
|
76
|
+
try {
|
|
77
|
+
const stat = statSync(path);
|
|
78
|
+
entries.push({ plan_sha256: name.slice(0, -5), saved_at_ms: stat.mtimeMs });
|
|
79
|
+
} catch { /* a vanished plan is simply absent */ }
|
|
80
|
+
}
|
|
81
|
+
entries.sort((a, b) => b.saved_at_ms - a.saved_at_ms);
|
|
82
|
+
return { ok: true, plans: entries.slice(0, Math.max(1, Math.min(200, limit))), total: entries.length };
|
|
26
83
|
}
|
|
27
84
|
|
|
28
85
|
/**
|
|
29
|
-
* 人工审批。authority
|
|
86
|
+
* 人工审批。authority 最低 70;TTL 默认 24h;过期/哈希不符/计划被替换时 dispatch 拒绝。
|
|
30
87
|
*/
|
|
31
|
-
export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now() }) {
|
|
32
|
-
if (
|
|
33
|
-
if (typeof approver !== 'string' || approver === '') return { ok: false, code: 'APPROVER_REQUIRED' };
|
|
88
|
+
export function approvePlan({ stateDir, planSha, approver, authority, note = '', now = Date.now(), ttlMs = DEFAULT_APPROVAL_TTL_MS }) {
|
|
89
|
+
if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
90
|
+
if (typeof approver !== 'string' || approver.trim() === '') return { ok: false, code: 'APPROVER_REQUIRED' };
|
|
34
91
|
if (!Number.isInteger(authority) || authority < 70) return { ok: false, code: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority } };
|
|
35
|
-
if (!
|
|
92
|
+
if (!Number.isInteger(ttlMs) || ttlMs < 60_000 || ttlMs > 7 * 24 * 60 * 60 * 1000) {
|
|
93
|
+
return { ok: false, code: 'APPROVAL_TTL_INVALID', detail: { ttlMs } };
|
|
94
|
+
}
|
|
95
|
+
const loaded = readPlanFile(stateDir, planSha);
|
|
96
|
+
if (loaded.ok !== true) return loaded;
|
|
36
97
|
mkdirSync(approvalDir(stateDir), { recursive: true });
|
|
37
98
|
const body = {
|
|
38
|
-
v:
|
|
99
|
+
v: 2,
|
|
39
100
|
plan_sha256: planSha,
|
|
40
101
|
approver,
|
|
41
102
|
authority,
|
|
42
103
|
note: String(note).slice(0, 200),
|
|
43
104
|
approved_at: new Date(now).toISOString(),
|
|
105
|
+
expires_at: new Date(now + ttlMs).toISOString(),
|
|
44
106
|
};
|
|
45
107
|
const record = { ...body, approval_sha256: sha256(JSON.stringify(body)) };
|
|
46
108
|
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
@@ -48,19 +110,28 @@ export function approvePlan({ stateDir, planSha, approver, authority, note = '',
|
|
|
48
110
|
return { ok: true, approval: record, path };
|
|
49
111
|
}
|
|
50
112
|
|
|
51
|
-
export function loadApproval({ stateDir, planSha }) {
|
|
113
|
+
export function loadApproval({ stateDir, planSha, now = Date.now() }) {
|
|
114
|
+
if (!PLAN_SHA.test(String(planSha ?? ''))) return { ok: false, code: 'PLAN_SHA_INVALID' };
|
|
52
115
|
const path = join(approvalDir(stateDir), planSha + '.json');
|
|
53
116
|
if (!existsSync(path)) return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
54
|
-
|
|
55
|
-
|
|
117
|
+
let record;
|
|
118
|
+
try { record = JSON.parse(readFileSync(path, 'utf8')); } catch { return { ok: false, code: 'APPROVAL_UNREADABLE' }; }
|
|
119
|
+
if (record?.plan_sha256 !== planSha) return { ok: false, code: 'APPROVAL_STALE' };
|
|
120
|
+
const { approval_sha256: declared, ...body } = record;
|
|
121
|
+
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: 'APPROVAL_AUTHORITY_TOO_LOW', detail: { authority: record.authority } };
|
|
124
|
+
}
|
|
125
|
+
const expires = Date.parse(record.expires_at ?? '');
|
|
126
|
+
if (Number.isFinite(expires) && now > expires) return { ok: false, code: 'APPROVAL_EXPIRED', detail: { expires_at: record.expires_at } };
|
|
56
127
|
return { ok: true, approval: record };
|
|
57
128
|
}
|
|
58
129
|
|
|
59
|
-
/** dispatch
|
|
60
|
-
export function assertDispatchAllowed({ stateDir, planSha }) {
|
|
130
|
+
/** dispatch 前置门:计划必须存在、未被篡改,且已审批、未过期。 */
|
|
131
|
+
export function assertDispatchAllowed({ stateDir, planSha, now = Date.now() }) {
|
|
61
132
|
const plan = loadPlan({ stateDir, planSha });
|
|
62
|
-
if (plan.ok !== true) return { ok: false, code: 'PLAN_NOT_FOUND' };
|
|
63
|
-
const approval = loadApproval({ stateDir, planSha });
|
|
64
|
-
if (approval.ok !== true) return { ok: false, code: 'APPROVAL_REQUIRED' };
|
|
133
|
+
if (plan.ok !== true) return { ok: false, code: plan.code ?? 'PLAN_NOT_FOUND' };
|
|
134
|
+
const approval = loadApproval({ stateDir, planSha, now });
|
|
135
|
+
if (approval.ok !== true) return { ok: false, code: approval.code ?? 'APPROVAL_REQUIRED' };
|
|
65
136
|
return { ok: true, plan: plan.plan, approval: approval.approval };
|
|
66
137
|
}
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* package in the profile, and takes a backup. Keeping the check pure is what makes "NPM 对比" testable
|
|
9
9
|
* without a network or a profile.
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
11
15
|
import { compareVersions } from './update-source.mjs';
|
|
12
16
|
|
|
13
17
|
/** The package this plugin is. */
|
|
@@ -83,3 +87,61 @@ export async function checkNpmUpdate(input) {
|
|
|
83
87
|
return decideNpmUpdate({ latest: last, currentVersion: input?.currentVersion ?? null });
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
/** Registry package documents to try, in order (mirror first, then npmjs). */
|
|
93
|
+
export const NPM_PACKAGE_DOC_URLS = [
|
|
94
|
+
`https://registry.npmmirror.com/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}`,
|
|
95
|
+
`https://registry.npmjs.org/${encodeURIComponent(PLUGIN_PACKAGE_NAME)}`,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Download an exact npm version and verify it against the registry's own integrity field before it
|
|
100
|
+
* can be installed. This is the fallback for "immediately update failed: fetch failed": GitHub
|
|
101
|
+
* release downloads are blocked on some networks while the npm registry is reachable.
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchNpmTarball(input) {
|
|
104
|
+
const fetchImpl = input?.fetchImpl ?? globalThis.fetch;
|
|
105
|
+
const version = typeof input?.version === 'string' ? input.version.trim() : '';
|
|
106
|
+
if (version === '') return { ok: false, error: 'version is required' };
|
|
107
|
+
const destDir = input?.destDir;
|
|
108
|
+
if (typeof destDir !== 'string' || destDir === '') return { ok: false, error: 'destDir is required' };
|
|
109
|
+
const timeoutMs = Number.isInteger(input?.timeoutMs) ? input.timeoutMs : NPM_CHECK_TIMEOUT_MS;
|
|
110
|
+
const urls = Array.isArray(input?.urls) ? input.urls : NPM_PACKAGE_DOC_URLS;
|
|
111
|
+
const signalOf = () => (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : undefined);
|
|
112
|
+
let meta = null;
|
|
113
|
+
let lastError = 'npm package document could not be read';
|
|
114
|
+
for (const url of urls) {
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetchImpl(url, { headers: { accept: 'application/json' }, signal: signalOf() });
|
|
117
|
+
if (response?.ok !== true) { lastError = `npm registry answered HTTP ${response?.status ?? '?'}`; continue; }
|
|
118
|
+
const doc = await response.json();
|
|
119
|
+
const candidate = doc?.versions?.[version];
|
|
120
|
+
if (candidate?.dist?.tarball === undefined) { lastError = `npm has no version ${version} with a tarball`; continue; }
|
|
121
|
+
meta = candidate;
|
|
122
|
+
break;
|
|
123
|
+
} catch (error) { lastError = error instanceof Error ? error.message : String(error); }
|
|
124
|
+
}
|
|
125
|
+
if (meta === null) return { ok: false, error: lastError };
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetchImpl(meta.dist.tarball, { signal: signalOf() });
|
|
128
|
+
if (response?.ok !== true) return { ok: false, error: `npm tarball answered HTTP ${response?.status ?? '?'}` };
|
|
129
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
130
|
+
const integrity = typeof meta.dist.integrity === 'string' ? meta.dist.integrity : null;
|
|
131
|
+
if (integrity !== null && /^sha512-/.test(integrity)) {
|
|
132
|
+
const actual = createHash('sha512').update(bytes).digest('base64');
|
|
133
|
+
if (actual !== integrity.slice('sha512-'.length)) return { ok: false, error: 'NPM_INTEGRITY_MISMATCH' };
|
|
134
|
+
} else if (typeof meta.dist.shasum === 'string') {
|
|
135
|
+
const actual = createHash('sha1').update(bytes).digest('hex');
|
|
136
|
+
if (actual !== meta.dist.shasum) return { ok: false, error: 'NPM_SHASUM_MISMATCH' };
|
|
137
|
+
} else {
|
|
138
|
+
return { ok: false, error: 'NPM_NO_INTEGRITY' };
|
|
139
|
+
}
|
|
140
|
+
mkdirSync(destDir, { recursive: true });
|
|
141
|
+
const path = join(destDir, `w2m-dsh-plugin-${version}.tgz`);
|
|
142
|
+
writeFileSync(path, bytes);
|
|
143
|
+
return { ok: true, path, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), version, tarball: meta.dist.tarball, integrityVerified: true };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
146
|
+
}
|
|
147
|
+
}
|