@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.
- 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/bin/w2m-operator.mjs +73 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.13/RELEASE-1913.json +20 -0
- package/docs/acceptance/1.9.14/RELEASE-1914.json +20 -0
- package/docs/acceptance/1.9.15/RELEASE-1915.json +22 -0
- package/package.json +265 -265
- package/plugin.json +1 -1
- package/src/plugin/npm-update.mjs +62 -0
- package/src/plugin/operator-token.mjs +83 -0
- package/src/plugin/tools.mjs +20 -5
- package/src/plugin/update-route.mjs +31 -0
- package/src/relay/repair-ledger.mjs +8 -2
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.15",
|
|
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": [
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
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.15';
|
|
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
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -4996,6 +5010,7 @@ const result = await request({
|
|
|
4996
5010
|
// wrong one without spending a dispatch, and `required_by_relay` is what makes the two
|
|
4997
5011
|
// halves of the mismatch (client has none / relay demands one) visible side by side.
|
|
4998
5012
|
operatorToken_configured: cfg.operatorToken !== null,
|
|
5013
|
+
operatorToken_fingerprint: cfg.operatorToken === null ? null : fingerprintOf(cfg.operatorToken),
|
|
4999
5014
|
operatorToken_source: cfg.operatorToken !== null
|
|
5000
5015
|
? (typeof config?.operatorToken === 'string' && config.operatorToken.trim() !== ''
|
|
5001
5016
|
? 'config'
|
|
@@ -86,6 +86,13 @@ export function registerPluginUpdateRoutes(ctx, deps) {
|
|
|
86
86
|
const result = await updater.check('manual');
|
|
87
87
|
const installedTo = typeof result?.to === 'string' && result.to !== '' ? result.to : null;
|
|
88
88
|
const installed = result?.outcome === 'installed' || installedTo !== null && installedTo !== current;
|
|
89
|
+
// The GitHub release download is blocked on some networks ("fetch failed"), while the npm
|
|
90
|
+
// registry is reachable. Before reporting a failure, install the same version from the
|
|
91
|
+
// registry tarball, whose integrity field is verified before pnpm sees it.
|
|
92
|
+
if (installed !== true) {
|
|
93
|
+
const fallback = await installFromNpmFallback({ profileDir, current, version: checked.latest ?? current });
|
|
94
|
+
if (fallback !== null) return Response.json(fallback.body, { headers: NO_STORE });
|
|
95
|
+
}
|
|
89
96
|
return Response.json({
|
|
90
97
|
ok: result?.ok === true && installed === true,
|
|
91
98
|
outcome: result?.outcome ?? 'unknown',
|
|
@@ -116,3 +123,27 @@ async function defaultUpdater({ profileDir, currentVersion }) {
|
|
|
116
123
|
log: () => {},
|
|
117
124
|
});
|
|
118
125
|
}
|
|
126
|
+
|
|
127
|
+
/** Install the same version from the npm registry when the GitHub release download fails. */
|
|
128
|
+
async function installFromNpmFallback({ profileDir, current, version }) {
|
|
129
|
+
try {
|
|
130
|
+
const { fetchNpmTarball } = await import('./npm-update.mjs');
|
|
131
|
+
const { installUpdate } = await import('./update-install.mjs');
|
|
132
|
+
const { tmpdir } = await import('node:os');
|
|
133
|
+
const fetched = await fetchNpmTarball({ version, destDir: join(tmpdir(), 'w2m-npm-update-' + Date.now()) });
|
|
134
|
+
if (fetched.ok !== true) return null;
|
|
135
|
+
const result = await installUpdate({ profileDir, tarballPath: fetched.path, expectedSha256: fetched.sha256 });
|
|
136
|
+
if (result?.ok !== true) return null;
|
|
137
|
+
return {
|
|
138
|
+
body: {
|
|
139
|
+
ok: true,
|
|
140
|
+
outcome: 'installed',
|
|
141
|
+
installedVia: 'npm-tarball',
|
|
142
|
+
from: result.from ?? current,
|
|
143
|
+
to: result.to ?? version,
|
|
144
|
+
installed: true,
|
|
145
|
+
restart_required: true,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
} catch { return null; }
|
|
149
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import {
|
|
18
18
|
chmodSync,
|
|
19
|
+
chownSync,
|
|
19
20
|
closeSync,
|
|
20
21
|
copyFileSync,
|
|
21
22
|
existsSync,
|
|
@@ -63,6 +64,7 @@ export function repairLedger(file, options = {}) {
|
|
|
63
64
|
const before = statSync(file);
|
|
64
65
|
if (!before.isFile()) return { ok: false, code: LEDGER_REPAIR_CODES.NOT_A_FILE, path: file };
|
|
65
66
|
|
|
67
|
+
const mode = before.mode & 0o777;
|
|
66
68
|
const raw = readFileSync(file);
|
|
67
69
|
const afterRead = statSync(file);
|
|
68
70
|
if (afterRead.mtimeMs !== before.mtimeMs || afterRead.size !== before.size) {
|
|
@@ -116,7 +118,8 @@ export function repairLedger(file, options = {}) {
|
|
|
116
118
|
const backup = `${file}.bak-${now}`;
|
|
117
119
|
try {
|
|
118
120
|
copyFileSync(file, backup);
|
|
119
|
-
chmodSync(backup,
|
|
121
|
+
chmodSync(backup, mode);
|
|
122
|
+
try { chownSync(backup, before.uid, before.gid); } catch { /* unprivileged run keeps the caller's owner */ }
|
|
120
123
|
} catch (error) {
|
|
121
124
|
return { ...base, dry_run: false, written: false, ok: false, code: LEDGER_REPAIR_CODES.WRITE_FAILED, error: String(error) };
|
|
122
125
|
}
|
|
@@ -132,7 +135,10 @@ export function repairLedger(file, options = {}) {
|
|
|
132
135
|
const next = valid.length === 0 ? '' : valid.join('\n') + (hasFinalNewline ? '\n' : '');
|
|
133
136
|
const tmp = `${file}.repair-${now}.tmp`;
|
|
134
137
|
try {
|
|
135
|
-
writeDurable(tmp, Buffer.from(next, 'utf8'),
|
|
138
|
+
writeDurable(tmp, Buffer.from(next, 'utf8'), mode);
|
|
139
|
+
// Keep the service account as the owner: a relay that runs as `w2m` must still be able to
|
|
140
|
+
// read the file after root repaired it (this exact break happened on Vultr).
|
|
141
|
+
try { chownSync(tmp, before.uid, before.gid); } catch { /* unprivileged */ }
|
|
136
142
|
renameSync(tmp, file);
|
|
137
143
|
} catch (error) {
|
|
138
144
|
try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best effort */ }
|