@twinsearth/w2m-dsh-plugin 1.9.12 → 1.9.15

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.
@@ -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.12"
8
+ "version": "1.9.15"
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.12",
15
+ "version": "1.9.15",
16
16
  "author": {
17
17
  "name": "TwinsEarth"
18
18
  },
@@ -26,5 +26,5 @@
26
26
  ]
27
27
  }
28
28
  ],
29
- "version": "1.9.12"
29
+ "version": "1.9.15"
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.12",
4
+ "version": "1.9.15",
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.15] - 2026-10-28
11
+
12
+ - 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 值不进日志/状态。
13
+ - 解析顺序:profile config(兼容)→ 安全文件(推荐)→ 环境变量(兼容)。
14
+ - 新增 test/operator-token.test.mjs(6 项)。
15
+
16
+ ## [1.9.14] - 2026-10-28
17
+
18
+ - 修复「立即更新」失败(fetch failed):GitHub Release 下载被网络阻断时,自动回退到 npm registry 的同一版本 tarball;下载前校验 registry 的 sha512 integrity(缺失时 sha1 shasum),再由现有安装器做 sha256 校验、备份与回滚。
19
+ - 安装器仍走运行时 pnpm(Windows 不再尝试 dsh.cmd)。
20
+ - 更新相关测试 66/66 通过。
21
+
22
+ ## [1.9.13] - 2026-10-28
23
+
24
+ - 新增操作员联合开发 CLI `w2m-joint`:plan create/list/get、approve(人签、authority≥70、TTL 默认 10 分钟)、status、dispatch --dry-run(打印控制信封,真实发送未接线并返回 DISPATCH_NOT_WIRED)。
25
+ - 该 CLI 是 SOP 的操作员入口:计划先落盘、人工审批、再允许派发;dispatch 真发仍要求 operator token 与 relay 能力广告(下一片)。
26
+ - 端到端本机冒烟:create → approve → status(dispatch_allowed=true) → dispatch --dry-run(2 envelopes)。
27
+
10
28
  ## [1.9.12] - 2026-10-28
11
29
 
12
30
  - 联合开发安全门(S1/S2 预置):plan-store 保存与读取都重算 plan_sha256(篡改即 PLAN_TAMPERED)、planSha 强制 64 位十六进制(拒绝路径穿越)、审批绑定 plan_sha/approver/authority/TTL 并重算 approval_sha256(篡改/过期/低权限拒绝)、原子写。
@@ -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);
@@ -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);
@@ -12,6 +12,6 @@
12
12
 
13
13
  [mcp_servers.w2m-fleet]
14
14
  command = "npx"
15
- args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.12", "w2m-mcp"]
15
+ args = ["-y", "-p", "@twinsearth/w2m-dsh-plugin@1.9.15", "w2m-mcp"]
16
16
  startup_timeout_sec = 20
17
17
  tool_timeout_sec = 60
@@ -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
+ }
@@ -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
+ }