@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Executable resolution shared by capability probing and task execution.
3
+ *
4
+ * This file exists because those two used to disagree, and the disagreement was
5
+ * invisible until a task ran.
6
+ *
7
+ * The capability probe deliberately looks for a tool **on PATH first, in DSH's
8
+ * bundled runtime second** — that is how a machine with no system `node` still
9
+ * reports `caps.node = "v24.21.0"` and means it. The executor, by contrast,
10
+ * spawned `argv[0]` verbatim with `shell: false`, so it could only ever find what
11
+ * PATH held. On a machine where the tool exists *only* in the bundle, the probe
12
+ * advertised a capability and every task that used it died at spawn with a bare
13
+ * `crashed`. One search order has to serve both, so it lives here.
14
+ *
15
+ * Nothing in this module guesses: a name that cannot be found is returned
16
+ * unchanged, so the caller still gets the real ENOENT instead of a fake path.
17
+ */
18
+
19
+ import { accessSync, constants, statSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { delimiter, join } from 'node:path';
22
+
23
+ /** The DSH runtime directory layout we fall back to (task-8, measured). */
24
+ export const DSH_RUNTIME_RELATIVE = join('dsh-runtimes', 'dsh-primary-runtime', 'dependencies');
25
+
26
+ /**
27
+ * `%DSH_HOME%` (or `~/.dsh`) — the root the bundled runtimes live under.
28
+ *
29
+ * @param {Record<string,string|undefined>} [env]
30
+ * @returns {string}
31
+ */
32
+ export function dshHome(env = process.env) {
33
+ const explicit = env.DSH_HOME && env.DSH_HOME.trim() !== '' ? env.DSH_HOME : null;
34
+ return explicit ?? join(homedir(), '.dsh');
35
+ }
36
+
37
+ /**
38
+ * Absolute path of the bundled DSH dependency directory, or `null` when absent.
39
+ *
40
+ * `DSH_RUNTIME_DEPS` overrides the guess, which keeps tests hermetic.
41
+ *
42
+ * @param {Record<string,string|undefined>} [env]
43
+ * @returns {string|null}
44
+ */
45
+ export function dshDependenciesDir(env = process.env) {
46
+ const override = env.DSH_RUNTIME_DEPS && env.DSH_RUNTIME_DEPS.trim() !== '';
47
+ const dir = override ? env.DSH_RUNTIME_DEPS : join(dshHome(env), DSH_RUNTIME_RELATIVE);
48
+ try {
49
+ return statSync(dir).isDirectory() ? dir : null;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Windows only: the extensions `CreateProcess` will accept for a bare name.
57
+ * (`.cmd`/`.bat` are listed so we can *report* them, but they cannot be
58
+ * started with `shell: false`, so they are never returned as executable.)
59
+ */
60
+ const WIN_EXTENSIONS = ['.exe', '.com'];
61
+
62
+ /**
63
+ * Find a real executable on PATH (then, optionally, in extra directories).
64
+ *
65
+ * @param {string} name Bare command name, e.g. `node`, `python3`.
66
+ * @param {object} [options]
67
+ * @param {Record<string,string|undefined>} [options.env]
68
+ * @param {string[]} [options.extraDirs] Absolute directories to try after PATH.
69
+ * @param {string[]} [options.extensions] Override the candidate extensions.
70
+ * @returns {string|null} Absolute path.
71
+ */
72
+ export function findExecutable(name, options = {}) {
73
+ const env = options.env ?? process.env;
74
+ const isWin = process.platform === 'win32';
75
+ const extensions = options.extensions ?? (isWin ? ['', ...WIN_EXTENSIONS] : ['']);
76
+ const dirs = [
77
+ ...String(env.PATH ?? '')
78
+ .split(delimiter)
79
+ .filter((d) => d.trim() !== ''),
80
+ ...(options.extraDirs ?? []),
81
+ ];
82
+
83
+ for (const dir of dirs) {
84
+ for (const ext of extensions) {
85
+ const candidate = join(dir, name + ext);
86
+ try {
87
+ accessSync(candidate, isWin ? constants.F_OK : constants.F_OK | constants.X_OK);
88
+ if (statSync(candidate).isFile()) return candidate;
89
+ } catch {
90
+ /* keep looking */
91
+ }
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * The `bin` directories inside DSH's bundled runtime tree.
99
+ *
100
+ * This is the second half of the probe's search order, expressed once. A tool
101
+ * found here is a tool the probe would have reported a real version for, which
102
+ * is exactly why the executor must be able to start it.
103
+ *
104
+ * @param {Record<string,string|undefined>} [env]
105
+ * @returns {string[]} Existing directories, in priority order.
106
+ */
107
+ export function bundledBinDirs(env = process.env) {
108
+ const depsDir = dshDependenciesDir(env);
109
+ if (!depsDir) return [];
110
+ const isWin = process.platform === 'win32';
111
+ const candidates = [
112
+ join(depsDir, 'node', 'bin'),
113
+ join(depsDir, 'node'),
114
+ join(depsDir, 'python', isWin ? '' : 'bin'),
115
+ join(depsDir, 'pnpm', 'bin'),
116
+ ];
117
+ return candidates.filter((dir) => {
118
+ try {
119
+ return statSync(dir).isDirectory();
120
+ } catch {
121
+ return false;
122
+ }
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Resolve `argv[0]` the way the capability probe resolves a tool.
128
+ *
129
+ * * A name containing a path separator is the caller's own path: returned
130
+ * unchanged, because rewriting an explicit path would be second-guessing.
131
+ * * Otherwise: PATH first, then DSH's bundled runtime.
132
+ * * Not found anywhere: returned unchanged, so `spawn` still reports ENOENT and
133
+ * the result keeps saying `crashed` for the honest reason.
134
+ *
135
+ * @param {string} exe Bare command name or a path.
136
+ * @param {object} [options]
137
+ * @param {Record<string,string|undefined>} [options.env]
138
+ * @param {string[]} [options.extraDirs] Extra directories tried after PATH.
139
+ * @returns {string} Absolute path when one was found, else `exe`.
140
+ */
141
+ export function resolveCommand(exe, options = {}) {
142
+ if (typeof exe !== 'string' || exe === '') return exe;
143
+ // Anything with a separator is a path the caller chose, not a name to look up.
144
+ if (exe.includes('/') || exe.includes('\\')) return exe;
145
+ const env = options.env ?? process.env;
146
+ const extraDirs = options.extraDirs ?? bundledBinDirs(env);
147
+ return findExecutable(exe, { env, extraDirs }) ?? exe;
148
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Write-ahead spool: "先落盘再回传" (§0).
3
+ *
4
+ * Ordering that the contract requires:
5
+ *
6
+ * 1. an offer is written to the spool **before** anything executes;
7
+ * 2. the finished envelope is written to the spool **before** it is POSTed;
8
+ * 3. the spool entry is deleted only after Rabbit confirms receipt.
9
+ *
10
+ * A crash between (2) and (3) therefore leaves a replayable envelope on disk,
11
+ * and a lost Rabbit (or a laptop lid closing mid-run) cannot silently swallow a
12
+ * result. Everything lives under the `--state` directory: read-only mode must
13
+ * never write into the project, and the spool is not part of the project.
14
+ */
15
+
16
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { randomUUID } from 'node:crypto';
19
+
20
+ /** Sub-directory of the state dir that holds spooled tasks. */
21
+ export const SPOOL_DIR_NAME = 'spool';
22
+
23
+ /** Entry states, in the order they occur. */
24
+ export const SPOOL_STATES = ['claimed', 'completed', 'acked'];
25
+
26
+ /**
27
+ * @typedef {object} SpoolRecord
28
+ * @property {string} task_id
29
+ * @property {number} attempt
30
+ * @property {string|null} dedupe_key
31
+ * @property {'claimed'|'completed'|'acked'} state
32
+ * @property {string} dir
33
+ * @property {string} created_at
34
+ * @property {string} updated_at
35
+ * @property {object|null} task
36
+ * @property {object|null} envelope
37
+ */
38
+
39
+ /** @param {string} value */
40
+ function sanitize(value) {
41
+ return String(value).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120);
42
+ }
43
+
44
+ /** Atomic file write: temp file in the same directory, then rename. */
45
+ function writeAtomic(file, text) {
46
+ const tmp = `${file}.tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
47
+ writeFileSync(tmp, text, { encoding: 'utf8', mode: 0o600 });
48
+ renameSync(tmp, file);
49
+ }
50
+
51
+ /**
52
+ * Create a spool rooted at `<stateDir>/spool`.
53
+ *
54
+ * @param {string} stateDir
55
+ * @returns {object} Spool handle.
56
+ */
57
+ export function createSpool(stateDir) {
58
+ if (typeof stateDir !== 'string' || stateDir.trim() === '') {
59
+ throw new TypeError('createSpool: stateDir must be a non-empty string');
60
+ }
61
+ const root = join(stateDir, SPOOL_DIR_NAME);
62
+ mkdirSync(root, { recursive: true, mode: 0o700 });
63
+
64
+ /** @param {string} taskId @param {number} attempt */
65
+ const taskDir = (taskId, attempt) => join(root, `${sanitize(taskId)}__a${Number(attempt) || 1}`);
66
+
67
+ /** @param {string} dir */
68
+ const readJson = (dir, name) => {
69
+ try {
70
+ return JSON.parse(readFileSync(join(dir, name), 'utf8'));
71
+ } catch {
72
+ return null;
73
+ }
74
+ };
75
+
76
+ /** @param {string} dir @returns {SpoolRecord|null} */
77
+ const readRecord = (dir) => {
78
+ const meta = readJson(dir, 'meta.json');
79
+ if (!meta || typeof meta.task_id !== 'string') return null;
80
+ return {
81
+ task_id: meta.task_id,
82
+ attempt: Number(meta.attempt) || 1,
83
+ dedupe_key: meta.dedupe_key ?? null,
84
+ state: meta.state ?? 'claimed',
85
+ dir,
86
+ created_at: meta.created_at ?? null,
87
+ updated_at: meta.updated_at ?? null,
88
+ task: readJson(dir, 'task.json'),
89
+ envelope: readJson(dir, 'envelope.json'),
90
+ };
91
+ };
92
+
93
+ const spool = {
94
+ /** Absolute spool root. */
95
+ root,
96
+ /** State directory the spool belongs to. */
97
+ stateDir,
98
+ taskDir,
99
+
100
+ /**
101
+ * Step 1: persist the offer before doing anything with it.
102
+ *
103
+ * @param {{task_id: string, attempt?: number, dedupe_key?: string|null, offer?: object|null}} entry
104
+ * @returns {SpoolRecord}
105
+ */
106
+ saveTask(entry) {
107
+ if (!entry || typeof entry.task_id !== 'string' || entry.task_id === '') {
108
+ throw new TypeError('spool.saveTask: task_id is required');
109
+ }
110
+ const attempt = Number(entry.attempt) || 1;
111
+ const dir = taskDir(entry.task_id, attempt);
112
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
113
+ const now = new Date().toISOString();
114
+ const existing = readJson(dir, 'meta.json');
115
+ if (entry.offer) writeAtomic(join(dir, 'task.json'), `${JSON.stringify(entry.offer, null, 2)}\n`);
116
+ const meta = {
117
+ task_id: entry.task_id,
118
+ attempt,
119
+ dedupe_key: entry.dedupe_key ?? existing?.dedupe_key ?? null,
120
+ state: 'claimed',
121
+ created_at: existing?.created_at ?? now,
122
+ updated_at: now,
123
+ };
124
+ writeAtomic(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
125
+ return readRecord(dir);
126
+ },
127
+
128
+ /**
129
+ * Step 2: persist the envelope before the POST.
130
+ *
131
+ * @param {{task_id: string, attempt?: number, envelope: object}} entry
132
+ * @returns {SpoolRecord}
133
+ */
134
+ saveEnvelope(entry) {
135
+ if (!entry || typeof entry.task_id !== 'string' || entry.task_id === '') {
136
+ throw new TypeError('spool.saveEnvelope: task_id is required');
137
+ }
138
+ if (!entry.envelope || typeof entry.envelope !== 'object') {
139
+ throw new TypeError('spool.saveEnvelope: envelope is required');
140
+ }
141
+ const attempt = Number(entry.attempt) || 1;
142
+ const dir = taskDir(entry.task_id, attempt);
143
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
144
+ writeAtomic(join(dir, 'envelope.json'), `${JSON.stringify(entry.envelope, null, 2)}\n`);
145
+ const existing = readJson(dir, 'meta.json') ?? {};
146
+ const now = new Date().toISOString();
147
+ const meta = {
148
+ task_id: entry.task_id,
149
+ attempt,
150
+ dedupe_key: entry.dedupe_key ?? entry.envelope.dedupe_key ?? existing.dedupe_key ?? null,
151
+ state: 'completed',
152
+ created_at: existing.created_at ?? now,
153
+ updated_at: now,
154
+ };
155
+ writeAtomic(join(dir, 'meta.json'), `${JSON.stringify(meta, null, 2)}\n`);
156
+ return readRecord(dir);
157
+ },
158
+
159
+ /**
160
+ * Every spool entry, oldest first.
161
+ *
162
+ * @returns {SpoolRecord[]}
163
+ */
164
+ list() {
165
+ let names;
166
+ try {
167
+ names = readdirSync(root);
168
+ } catch {
169
+ return [];
170
+ }
171
+ const records = [];
172
+ for (const name of names) {
173
+ const dir = join(root, name);
174
+ try {
175
+ if (!statSync(dir).isDirectory()) continue;
176
+ } catch {
177
+ continue;
178
+ }
179
+ const record = readRecord(dir);
180
+ if (record) records.push(record);
181
+ }
182
+ records.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
183
+ return records;
184
+ },
185
+
186
+ /**
187
+ * Entries whose envelope has not been acknowledged yet.
188
+ *
189
+ * @returns {SpoolRecord[]}
190
+ */
191
+ pendingResults() {
192
+ return spool.list().filter((record) => record.envelope !== null);
193
+ },
194
+
195
+ /**
196
+ * Entries claimed but never completed (a crash mid-run).
197
+ *
198
+ * @returns {SpoolRecord[]}
199
+ */
200
+ interruptedTasks() {
201
+ return spool.list().filter((record) => record.envelope === null);
202
+ },
203
+
204
+ /**
205
+ * Step 3: drop the entry once Rabbit confirmed it.
206
+ *
207
+ * @param {{task_id: string, attempt?: number}} entry
208
+ * @returns {boolean} Whether an entry was removed.
209
+ */
210
+ ack(entry) {
211
+ const dir = taskDir(entry.task_id, Number(entry.attempt) || 1);
212
+ try {
213
+ statSync(dir);
214
+ } catch {
215
+ return false;
216
+ }
217
+ rmSync(dir, { recursive: true, force: true });
218
+ return true;
219
+ },
220
+
221
+ /**
222
+ * Acknowledge by dedupe key (Rabbit may answer `deduped: true`).
223
+ *
224
+ * @param {string} dedupeKey
225
+ * @returns {number} Count removed.
226
+ */
227
+ ackByDedupeKey(dedupeKey) {
228
+ let removed = 0;
229
+ for (const record of spool.list()) {
230
+ if (record.dedupe_key && record.dedupe_key === dedupeKey) {
231
+ rmSync(record.dir, { recursive: true, force: true });
232
+ removed += 1;
233
+ }
234
+ }
235
+ return removed;
236
+ },
237
+
238
+ /** @returns {number} Entry count. */
239
+ size() {
240
+ return spool.list().length;
241
+ },
242
+
243
+ /** Remove everything (tests, `--reset-spool`). */
244
+ clear() {
245
+ const records = spool.list();
246
+ for (const record of records) rmSync(record.dir, { recursive: true, force: true });
247
+ return records.length;
248
+ },
249
+ };
250
+
251
+ return spool;
252
+ }