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

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 +1309 -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 +24 -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,356 @@
1
+ /**
2
+ * Direct argv execution for the W2M Localside agent.
3
+ *
4
+ * Two invariants matter more than anything else in this file:
5
+ *
6
+ * 1. **No shell, ever.** `spawn(file, args, {shell: false})` is the only way
7
+ * a command is started. A string is not accepted as `argv`, so a caller
8
+ * cannot smuggle a command line in by accident; the type check throws.
9
+ * This is what keeps `node --test && rm -rf /` from being two commands.
10
+ *
11
+ * 2. **Raw byte streams are the evidence.** `stdout_sha256` / `stdout_bytes`
12
+ * describe the *entire* stream, not the retained sample, so a result stays
13
+ * comparable between machines even when a chatty command prints more than
14
+ * we are willing to keep in memory. Truncation only limits what we retain
15
+ * for `stdout_head` / `stdout_tail`, and it is reported as a warning.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { createHash } from 'node:crypto';
20
+ import { resolveCommand } from './resolve.mjs';
21
+
22
+ /** Per-stream default retention cap (task-8: 2 MiB each). */
23
+ export const DEFAULT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
24
+
25
+ /** §5.2: `stdout_head` / `stdout_tail` carry 4 KiB each. */
26
+ export const SAMPLE_BYTES = 4 * 1024;
27
+
28
+ /** Warning code emitted when we retained less than the command produced. */
29
+ export const WARN_OUTPUT_TRUNCATED = 'OUTPUT_TRUNCATED';
30
+
31
+ /** Warning code emitted when the executable could not be started at all. */
32
+ export const WARN_PATH_INVALID = 'PATH_INVALID';
33
+
34
+ /**
35
+ * Lowercase hex SHA-256 of a Buffer/string.
36
+ *
37
+ * @param {Buffer|Uint8Array|string} data
38
+ * @returns {string}
39
+ */
40
+ export function sha256Hex(data) {
41
+ return createHash('sha256').update(data).digest('hex');
42
+ }
43
+
44
+ /** SHA-256 of the empty stream; used by refusal envelopes. */
45
+ export const EMPTY_SHA256 = sha256Hex(Buffer.alloc(0));
46
+
47
+ /**
48
+ * @typedef {object} ExecResult
49
+ * @property {string[]} argv
50
+ * @property {string} cwd
51
+ * @property {number|null} exit_code Exit status, `null` when killed by a signal.
52
+ * @property {string|null} signal Signal name, `null` on a normal exit.
53
+ * @property {Buffer} stdout Retained prefix of stdout (<= maxOutputBytes).
54
+ * @property {Buffer} stderr Retained prefix of stderr (<= maxOutputBytes).
55
+ * @property {number} stdout_bytes Total bytes seen on stdout.
56
+ * @property {number} stderr_bytes Total bytes seen on stderr.
57
+ * @property {string} stdout_sha256 SHA-256 of the *entire* stdout stream.
58
+ * @property {string} stderr_sha256 SHA-256 of the *entire* stderr stream.
59
+ * @property {string} stdout_head First 4 KiB of stdout, UTF-8 (lossy).
60
+ * @property {string} stdout_tail Last 4 KiB of stdout, UTF-8 (lossy).
61
+ * @property {string} stderr_head First 4 KiB of stderr, UTF-8 (lossy).
62
+ * @property {string} stderr_tail Last 4 KiB of stderr, UTF-8 (lossy).
63
+ * @property {boolean} truncated_stdout True when stdout exceeded the retention cap.
64
+ * @property {boolean} truncated_stderr True when stderr exceeded the retention cap.
65
+ * @property {string} started_at RFC3339 UTC.
66
+ * @property {string} ended_at RFC3339 UTC.
67
+ * @property {number} duration_ms Wall-clock duration.
68
+ * @property {boolean} timed_out We killed it because `timeoutMs` elapsed.
69
+ * @property {boolean} cancelled We killed it because the signal aborted.
70
+ * @property {{code: string, message: string}|null} spawn_error
71
+ * @property {string[]} warnings
72
+ */
73
+
74
+ /**
75
+ * Streaming collector: hashes and counts everything, retains a capped prefix
76
+ * plus a rolling 4 KiB tail.
77
+ *
78
+ * @param {number} maxOutputBytes
79
+ */
80
+ function createCollector(maxOutputBytes) {
81
+ /** @type {Buffer[]} */
82
+ const heads = [];
83
+ /** @type {Buffer[]} */
84
+ const tails = [];
85
+ let kept = 0;
86
+ let total = 0;
87
+ let tailLen = 0;
88
+ const hasher = createHash('sha256');
89
+
90
+ return {
91
+ /** @param {Buffer} chunk */
92
+ push(chunk) {
93
+ total += chunk.length;
94
+ hasher.update(chunk);
95
+ if (kept < maxOutputBytes) {
96
+ const room = maxOutputBytes - kept;
97
+ const piece = chunk.length <= room ? chunk : chunk.subarray(0, room);
98
+ heads.push(piece);
99
+ kept += piece.length;
100
+ }
101
+ tails.push(chunk);
102
+ tailLen += chunk.length;
103
+ // Drop whole chunks from the front while the rest still covers 4 KiB.
104
+ while (tails.length > 1 && tailLen - tails[0].length >= SAMPLE_BYTES) {
105
+ tailLen -= tails.shift().length;
106
+ }
107
+ },
108
+ finish() {
109
+ const all = Buffer.concat(tails, tailLen);
110
+ return {
111
+ out: Buffer.concat(heads, kept),
112
+ head: all.subarray(0, Math.min(SAMPLE_BYTES, all.length)),
113
+ tail: all.length > SAMPLE_BYTES ? all.subarray(all.length - SAMPLE_BYTES) : all,
114
+ bytes: total,
115
+ sha: hasher.digest('hex'),
116
+ truncated: total > kept,
117
+ };
118
+ },
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Start `argv` directly, capture both streams, enforce a timeout.
124
+ *
125
+ * Never rejects: a failure to even start the process is part of the result,
126
+ * because "could not spawn" is a result the Rabbit side has to see.
127
+ *
128
+ * @param {string[]} argv Command and arguments; `argv[0]` is the executable.
129
+ * @param {object} [options]
130
+ * @param {string} [options.cwd]
131
+ * @param {Record<string,string|undefined>} [options.env] Extra environment entries.
132
+ * @param {number} [options.timeoutMs] 0/undefined means "no timeout".
133
+ * @param {number} [options.maxOutputBytes] Per-stream retention cap.
134
+ * @param {AbortSignal} [options.signal] Cancellation (task.cancel / shutdown).
135
+ * @param {string} [options.killSignal]
136
+ * @param {number} [options.killGraceMs] Delay before escalating to SIGKILL.
137
+ * @param {boolean} [options.resolveExecutable] Look `argv[0]` up the way the
138
+ * capability probe does — PATH first, DSH's bundled runtime second —
139
+ * instead of handing the bare name to `spawn`. Defaults to true, so a
140
+ * capability the probe advertised is one a task can actually use. Set
141
+ * false to spawn `argv[0]` verbatim.
142
+ * @returns {Promise<ExecResult>}
143
+ */
144
+ export function runArgv(argv, options = {}) {
145
+ if (!Array.isArray(argv) || argv.length === 0) {
146
+ throw new TypeError('runArgv: argv must be a non-empty array of strings');
147
+ }
148
+ for (const part of argv) {
149
+ if (typeof part !== 'string') {
150
+ throw new TypeError(
151
+ `runArgv: argv entries must be strings, got ${part === null ? 'null' : typeof part}`,
152
+ );
153
+ }
154
+ }
155
+ if (argv[0] === '') throw new TypeError('runArgv: argv[0] must not be empty');
156
+
157
+ const {
158
+ cwd = process.cwd(),
159
+ env: extraEnv,
160
+ timeoutMs = 0,
161
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
162
+ signal,
163
+ killSignal = 'SIGTERM',
164
+ killGraceMs = 2000,
165
+ resolveExecutable = true,
166
+ } = options;
167
+
168
+ return new Promise((resolve) => {
169
+ const outCollector = createCollector(maxOutputBytes);
170
+ const errCollector = createCollector(maxOutputBytes);
171
+
172
+ const startedAtWall = new Date();
173
+ const startedHr = process.hrtime.bigint();
174
+
175
+ let settled = false;
176
+ let timedOut = false;
177
+ let cancelled = false;
178
+ let timeoutTimer = null;
179
+ let graceTimer = null;
180
+ /** @type {import('node:child_process').ChildProcess} */
181
+ let child;
182
+
183
+ const finish = (exitCode, signalName, spawnError) => {
184
+ if (settled) return;
185
+ settled = true;
186
+ if (timeoutTimer) clearTimeout(timeoutTimer);
187
+ if (graceTimer) clearTimeout(graceTimer);
188
+ if (signal) signal.removeEventListener('abort', onAbort);
189
+
190
+ const out = outCollector.finish();
191
+ const err = errCollector.finish();
192
+ const durationMs = Number(process.hrtime.bigint() - startedHr) / 1e6;
193
+
194
+ const warnings = [];
195
+ if (out.truncated || err.truncated) warnings.push(WARN_OUTPUT_TRUNCATED);
196
+ if (spawnError) warnings.push(WARN_PATH_INVALID);
197
+
198
+ resolve({
199
+ argv: [...argv],
200
+ cwd,
201
+ exit_code: exitCode,
202
+ signal: signalName,
203
+ stdout: out.out,
204
+ stderr: err.out,
205
+ stdout_bytes: out.bytes,
206
+ stderr_bytes: err.bytes,
207
+ stdout_sha256: out.sha,
208
+ stderr_sha256: err.sha,
209
+ stdout_head: out.head.toString('utf8'),
210
+ stdout_tail: out.tail.toString('utf8'),
211
+ stderr_head: err.head.toString('utf8'),
212
+ stderr_tail: err.tail.toString('utf8'),
213
+ truncated_stdout: out.truncated,
214
+ truncated_stderr: err.truncated,
215
+ started_at: startedAtWall.toISOString(),
216
+ ended_at: new Date().toISOString(),
217
+ duration_ms: Math.round(durationMs),
218
+ timed_out: timedOut,
219
+ cancelled,
220
+ spawn_error: spawnError,
221
+ warnings,
222
+ });
223
+ };
224
+
225
+ /** Kill, then escalate if the process ignores SIGTERM. */
226
+ const killChild = () => {
227
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
228
+ try {
229
+ child.kill(killSignal);
230
+ } catch {
231
+ /* already gone */
232
+ }
233
+ if (killGraceMs > 0) {
234
+ graceTimer = setTimeout(() => {
235
+ try {
236
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
237
+ } catch {
238
+ /* already gone */
239
+ }
240
+ }, killGraceMs);
241
+ if (typeof graceTimer.unref === 'function') graceTimer.unref();
242
+ }
243
+ };
244
+
245
+ const onAbort = () => {
246
+ cancelled = true;
247
+ killChild();
248
+ };
249
+
250
+ try {
251
+ // The probe resolves a tool by PATH first, then in DSH's bundled runtime, so a machine with no
252
+ // system `node` still reports a real `caps.node`. Execution has to use the same order, or the
253
+ // capability is one the machine advertises and cannot use: `spawn('node', …, {shell:false})`
254
+ // only ever searches PATH.
255
+ const childEnv = extraEnv ? { ...process.env, ...extraEnv } : process.env;
256
+ const executable = resolveExecutable ? resolveCommand(argv[0], { env: childEnv }) : argv[0];
257
+ child = spawn(executable, argv.slice(1), {
258
+ cwd,
259
+ env: childEnv,
260
+ shell: false, // non-negotiable
261
+ windowsHide: true,
262
+ stdio: ['ignore', 'pipe', 'pipe'],
263
+ detached: false,
264
+ });
265
+ } catch (error) {
266
+ finish(null, null, {
267
+ code: error?.code ?? 'SPAWN_FAILED',
268
+ message: String(error?.message ?? error),
269
+ });
270
+ return;
271
+ }
272
+
273
+ child.stdout?.on('data', (chunk) => outCollector.push(chunk));
274
+ child.stderr?.on('data', (chunk) => errCollector.push(chunk));
275
+
276
+ child.on('error', (error) => {
277
+ finish(null, null, {
278
+ code: error?.code ?? 'SPAWN_FAILED',
279
+ message: String(error?.message ?? error),
280
+ });
281
+ });
282
+
283
+ child.on('close', (code, signalName) => {
284
+ const signalled = signalName !== null && signalName !== undefined;
285
+ // The contract wants exit_code null whenever a signal killed the process.
286
+ finish(signalled ? null : code, signalled ? String(signalName) : null, null);
287
+ });
288
+
289
+ if (timeoutMs > 0) {
290
+ timeoutTimer = setTimeout(() => {
291
+ timedOut = true;
292
+ killChild();
293
+ }, timeoutMs);
294
+ if (typeof timeoutTimer.unref === 'function') timeoutTimer.unref();
295
+ }
296
+
297
+ if (signal) {
298
+ if (signal.aborted) {
299
+ cancelled = true;
300
+ killChild();
301
+ } else {
302
+ signal.addEventListener('abort', onAbort, { once: true });
303
+ }
304
+ }
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Map an execution result onto the contract's `status` enum.
310
+ *
311
+ * `refused` / `unverifiable` are decided by the caller, not here.
312
+ *
313
+ * @param {ExecResult} result
314
+ * @returns {'ok'|'nonzero_exit'|'timeout'|'crashed'}
315
+ */
316
+ export function classifyExit(result) {
317
+ if (result.timed_out) return 'timeout';
318
+ if (result.spawn_error) return 'crashed';
319
+ if (result.cancelled) return 'crashed';
320
+ if (result.exit_code === null) return 'crashed'; // killed by a signal
321
+ return result.exit_code === 0 ? 'ok' : 'nonzero_exit';
322
+ }
323
+
324
+ // Control characters are the point of these patterns: they match the ANSI escape sequences a
325
+ // terminal emits. `no-control-regex` exists to catch control characters that slipped in by accident,
326
+ // which is the opposite of what is happening here, so the rule is disabled per line rather than
327
+ // globally -- a genuinely accidental control character elsewhere must still be reported.
328
+ // eslint-disable-next-line no-control-regex -- matches ANSI OSC terminators by definition
329
+ const ANSI_OSC = /\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
330
+ // eslint-disable-next-line no-control-regex -- matches ANSI CSI introducers by definition
331
+ const ANSI_CSI = /\u001B\[[0-9;?]*[ -/]*[@-~]/g;
332
+ // eslint-disable-next-line no-control-regex -- matches ANSI single-character escapes by definition
333
+ const ANSI_OTHER = /\u001B[@-Z\\-_]/g;
334
+
335
+ /**
336
+ * Apply `compare_policy` to a captured stream so that two machines can agree
337
+ * on `stdout_normalized_sha256` despite ANSI colour and CRLF differences.
338
+ *
339
+ * The transform is lossy on purpose and intentionally *not* used for
340
+ * `stdout_sha256`, which stays the raw byte stream.
341
+ *
342
+ * @param {Buffer|string} data
343
+ * @param {{strip_ansi?: boolean, normalize_crlf?: boolean, strip_trailing_blank_lines?: boolean}} [policy]
344
+ * @returns {Buffer}
345
+ */
346
+ export function normalizeOutput(data, policy = {}) {
347
+ const stripAnsi = policy.strip_ansi !== false;
348
+ const normalizeCrlf = policy.normalize_crlf !== false;
349
+ const stripTrailingBlank = policy.strip_trailing_blank_lines !== false;
350
+
351
+ let text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
352
+ if (stripAnsi) text = text.replace(ANSI_OSC, '').replace(ANSI_CSI, '').replace(ANSI_OTHER, '');
353
+ if (normalizeCrlf) text = text.replace(/\r\n/g, '\n');
354
+ if (stripTrailingBlank) text = text.replace(/(?:\n[ \t]*)+$/, '');
355
+ return Buffer.from(text, 'utf8');
356
+ }