@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2

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 (42) hide show
  1. package/CHANGELOG.md +1176 -0
  2. package/HANDOFF.md +173 -0
  3. package/README.md +9 -2
  4. package/bin/w2m-localside.mjs +325 -308
  5. package/bin/w2m-rabbit.mjs +6 -0
  6. package/bin/w2m-stun.mjs +0 -0
  7. package/deploy/mac/mac-self-update.mjs +257 -0
  8. package/deploy/server/README.md +109 -0
  9. package/deploy/server/deploy-w2m.sh +161 -0
  10. package/deploy/server/w2m-localside.service +31 -0
  11. package/deploy/server/w2m-rabbit.service +26 -0
  12. package/deploy/server/w2m-stun.service +26 -0
  13. package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
  14. package/package.json +34 -22
  15. package/src/agent/agent.mjs +24 -2
  16. package/src/chatops/account.mjs +401 -0
  17. package/src/chatops/actions.mjs +227 -0
  18. package/src/chatops/approval-broker.mjs +320 -0
  19. package/src/chatops/auth.mjs +376 -0
  20. package/src/chatops/bridge.mjs +344 -0
  21. package/src/chatops/channel.mjs +192 -0
  22. package/src/chatops/commands.mjs +409 -0
  23. package/src/chatops/config-route.mjs +332 -0
  24. package/src/chatops/device-signing.mjs +218 -0
  25. package/src/chatops/durability.mjs +202 -0
  26. package/src/chatops/file-safety.mjs +282 -0
  27. package/src/chatops/fleet-join.mjs +164 -0
  28. package/src/chatops/fleet-scope.mjs +176 -0
  29. package/src/chatops/fleet-wiring.mjs +258 -0
  30. package/src/chatops/fleet.mjs +272 -0
  31. package/src/chatops/handoff.mjs +258 -0
  32. package/src/chatops/ilink-fleet-bridge.mjs +197 -0
  33. package/src/chatops/ilink-protocol.mjs +165 -0
  34. package/src/chatops/ilink-transport.mjs +168 -0
  35. package/src/chatops/ilink.mjs +423 -0
  36. package/src/chatops/index.mjs +440 -0
  37. package/src/chatops/outbound-queue.mjs +282 -0
  38. package/src/chatops/policy.mjs +356 -0
  39. package/src/chatops/run-command.mjs +308 -0
  40. package/src/plugin/tools.mjs +1 -1
  41. package/src/relay/server.mjs +96 -0
  42. package/src/relay/state.mjs +2279 -2011
@@ -1,308 +1,325 @@
1
- #!/usr/bin/env node
2
- /**
3
- * `w2m-localside` — the Windows/macOS side of a W2M pair.
4
- *
5
- * node bin/w2m-localside.mjs \
6
- * --rabbit http://127.0.0.1:8787 \
7
- * --pair PAIR-7K2M9QX4 \
8
- * --project E:/DS/w2m \
9
- * --name win-desktop \
10
- * --allowed-commands '["node --test","git status"]' \
11
- * --state ~/.dsh/xclient/localside \
12
- * --once
13
- *
14
- * The allow-list is **default-deny**: with no `--allowed-commands`, every offer
15
- * is refused with `COMMAND_NOT_ALLOWED`. That is deliberate -- an agent that
16
- * runs whatever it is told is not a thing this project ships.
17
- *
18
- * State (identity file excluded) lives under `--state`, never in the project,
19
- * so `write: false` offers cannot dirty the worktree they are measuring.
20
- */
21
-
22
- import { parseArgs as parseNodeArgs } from 'node:util';
23
- import process from 'node:process';
24
-
25
- import { formatError, resolveStateDir } from '../src/util/cli.mjs';
26
- import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
27
- import { probeCapsDetailed, detectPlatform } from '../src/agent/caps.mjs';
28
- import { loadOrCreateIdentity, saveDeviceToken } from '../src/agent/identity.mjs';
29
- import { normalizeP2PMode, normalizeP2PPort, stunServersWithShared } from '../src/agent/p2p-node.mjs';
30
- import { resolveBaseUrl } from '../src/agent/url.mjs';
31
-
32
- const USAGE = `w2m-localside — W2M Localside agent (protocol v1)
33
-
34
- Usage:
35
- w2m-localside --rabbit <url> [--pair <CODE>] [--project <dir>]
36
- [--name <name>] [--allowed-commands <json>]
37
- [--state <dir>] [--p2p-mode <mode>] [--p2p-port <n>]
38
- [--stun-servers <list>]
39
- [--once] [--once-idle-ms <ms>]
40
-
41
- Options:
42
- --rabbit <url> Rabbit base address (default: $W2M_RABBIT_URL).
43
- A sub-path is supported: https://host/w2m
44
- Query strings and fragments are rejected.
45
- --pair <CODE> One-time pairing code, e.g. PAIR-7K2M9QX4
46
- --project <dir> Project root to run in (default: current directory)
47
- --name <name> machine_name (default: <os>-<hostname>)
48
- --allowed-commands <json> Default-deny allow-list, JSON array of prefixes.
49
- Example: '["node --test","git status"]'
50
- Nothing is allowed when omitted.
51
- --state <dir> State directory for spool/state
52
- (default: <DSH_HOME>/xclient/localside)
53
- --p2p-mode <mode> auto (default) | direct | relay
54
- (default: $W2M_P2P_MODE)
55
- auto: accept an offer over either path
56
- direct: refuse an offer that did not arrive
57
- directly (P2P_UNAVAILABLE)
58
- relay: v0.3.9 - no UDP socket is bound at all
59
- --p2p-port <n> Local UDP port for the punch socket, 1..65535
60
- (default: $W2M_P2P_PORT, or an ephemeral port)
61
- Pin this on a machine that must *accept* a punch:
62
- an ephemeral port changes on every restart, so the
63
- firewall rule has to be re-pointed each time and
64
- every dispatch falls back to the relay until it is.
65
- --stun-servers <list> Comma-separated STUN servers, queried in order,
66
- after the shared server (default: $W2M_STUN_SERVERS)
67
- Example: stun.internal:3478,stun.l.google.com:19302
68
- --operator-token <t> Operator token (or $W2M_OPERATOR_TOKEN). Only needed
69
- when this host also submits tasks; the Localside
70
- agent itself never sends it.
71
- --once Handle one offer, then exit
72
- --once-idle-ms <ms> With --once: give up if no offer arrives in <ms>
73
- --heartbeat-ms <ms> Lease heartbeat interval (default: 10000)
74
- --help Show this message
75
-
76
- Exit codes: 0 stopped cleanly, 1 runtime error, 2 usage error.`;
77
-
78
- /**
79
- * Parse argv, tolerating `--flag=value` and `--flag value`.
80
- *
81
- * @param {string[]} argv
82
- */
83
- function parseCli(argv) {
84
- const { values, positionals } = parseNodeArgs({
85
- args: argv,
86
- allowPositionals: false,
87
- strict: true,
88
- options: {
89
- rabbit: { type: 'string' },
90
- pair: { type: 'string' },
91
- project: { type: 'string' },
92
- name: { type: 'string' },
93
- 'allowed-commands': { type: 'string' },
94
- 'operator-token': { type: 'string' },
95
- state: { type: 'string' },
96
- 'p2p-mode': { type: 'string' },
97
- 'p2p-port': { type: 'string' },
98
- 'stun-servers': { type: 'string' },
99
- once: { type: 'boolean', default: false },
100
- 'once-idle-ms': { type: 'string' },
101
- 'heartbeat-ms': { type: 'string' },
102
- help: { type: 'boolean', default: false },
103
- },
104
- });
105
- void positionals;
106
- return values;
107
- }
108
-
109
- async function main() {
110
- let flags;
111
- try {
112
- flags = parseCli(process.argv.slice(2));
113
- } catch (error) {
114
- process.stderr.write(`${formatError(error)}\n\n${USAGE}\n`);
115
- return 2;
116
- }
117
-
118
- if (flags.help) {
119
- process.stdout.write(`${USAGE}\n`);
120
- return 0;
121
- }
122
-
123
- const rabbitUrl = flags.rabbit ?? process.env.W2M_RABBIT_URL ?? '';
124
- if (rabbitUrl.trim() === '') {
125
- process.stderr.write('error: --rabbit <url> is required (or set W2M_RABBIT_URL)\n\n' + `${USAGE}\n`);
126
- return 2;
127
- }
128
- // Validate the base address before doing anything else: a sub-path is fine,
129
- // a query string / fragment / non-http scheme is a usage error (v0.1.2 §2).
130
- let baseUrl;
131
- try {
132
- baseUrl = resolveBaseUrl(rabbitUrl);
133
- } catch (error) {
134
- process.stderr.write(`error: ${formatError(error)}\n`);
135
- return 2;
136
- }
137
-
138
- const operatorToken = flags['operator-token'] ?? process.env.W2M_OPERATOR_TOKEN ?? null;
139
-
140
- let allowedCommands;
141
- try {
142
- allowedCommands = parseAllowedCommands(flags['allowed-commands'] ?? '[]');
143
- } catch (error) {
144
- process.stderr.write(`error: ${formatError(error)}\n`);
145
- return 2;
146
- }
147
-
148
- const project = flags.project ?? process.cwd();
149
- const stateDir = resolveStateDir(flags.state, 'localside');
150
- const onceIdleMs = flags['once-idle-ms'] ? Number.parseInt(flags['once-idle-ms'], 10) : 0;
151
- const heartbeatMs = flags['heartbeat-ms']
152
- ? Number.parseInt(flags['heartbeat-ms'], 10)
153
- : undefined;
154
-
155
- if (Number.isNaN(onceIdleMs) || onceIdleMs < 0) {
156
- process.stderr.write('error: --once-idle-ms must be a non-negative integer\n');
157
- return 2;
158
- }
159
- if (heartbeatMs !== undefined && (Number.isNaN(heartbeatMs) || heartbeatMs <= 0)) {
160
- process.stderr.write('error: --heartbeat-ms must be a positive integer\n');
161
- return 2;
162
- }
163
-
164
- // v0.4.0. The flag wins over the environment, and both go through the same loud validator the
165
- // node and the agent use: a typo is a usage error (exit 2) naming the setting, exactly like
166
- // `--rabbit` or `--once-idle-ms`, and never a silent fallback to the default. A mode that
167
- // silently became `auto` would turn `direct` into a promise the operator only discovers was
168
- // broken when a punch failed.
169
- const p2pModeInput = flags['p2p-mode'] ?? process.env.W2M_P2P_MODE ?? undefined;
170
- const p2pModeResult = normalizeP2PMode(p2pModeInput === undefined ? 'auto' : p2pModeInput);
171
- if (!p2pModeResult.ok) {
172
- const source = flags['p2p-mode'] !== undefined ? '--p2p-mode' : 'W2M_P2P_MODE';
173
- process.stderr.write(`error: ${source}: ${p2pModeResult.reason}\n`);
174
- return 2;
175
- }
176
- const p2pMode = p2pModeResult.mode;
177
-
178
- // Same precedence rule, same reason, and the same loud validator the node and the agent use.
179
- // Omitting the setting keeps the ephemeral port, which is the right default for a machine that
180
- // only dials. A value that *is* written must be a real port: `0` is refused by name rather than
181
- // read as "let the OS choose", because an operator who writes `0` is asking for a stable port and
182
- // would silently get a random one -- see `normalizeP2PPort`.
183
- const p2pPortInput = flags['p2p-port'] ?? process.env.W2M_P2P_PORT ?? undefined;
184
- const p2pPortResult = normalizeP2PPort(p2pPortInput);
185
- if (!p2pPortResult.ok) {
186
- const source = flags['p2p-port'] !== undefined ? '--p2p-port' : 'W2M_P2P_PORT';
187
- process.stderr.write(`error: ${source}: ${p2pPortResult.reason}\n`);
188
- return 2;
189
- }
190
- const p2pPort = p2pPortResult.port;
191
-
192
- // Same precedence rule, same reason. An empty list means "the shared server and the public
193
- // fallbacks", i.e. the default -- `stunServersWithShared()` accepts '' and drops it -- so an
194
- // accidentally blank variable is harmless rather than a node that cannot discover anything.
195
- const stunInput = flags['stun-servers'] ?? process.env.W2M_STUN_SERVERS ?? '';
196
- let stunServers;
197
- try {
198
- stunServers = stunServersWithShared(stunInput);
199
- } catch (error) {
200
- const source = flags['stun-servers'] !== undefined ? '--stun-servers' : 'W2M_STUN_SERVERS';
201
- process.stderr.write(`error: ${source}: ${formatError(error)}\n`);
202
- return 2;
203
- }
204
-
205
- const log = (level, message, extra) => {
206
- const suffix = extra && Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : '';
207
- process.stdout.write(`${new Date().toISOString()} ${level.padEnd(7)} ${message}${suffix}\n`);
208
- };
209
-
210
- /** One-shot guard for the resolved-P2P startup line (see `onP2PStatus` below). */
211
- let p2pLogged = false;
212
-
213
- try {
214
- const { identity, path: identityPath, created } = loadOrCreateIdentity({
215
- name: flags.name,
216
- rabbitUrl: baseUrl,
217
- });
218
- log('info', `${created ? 'created' : 'loaded'} identity ${identity.machine_id} (${identityPath})`);
219
-
220
- const [{ caps, detail }, platform] = await Promise.all([
221
- probeCapsDetailed({}),
222
- detectPlatform({}),
223
- ]);
224
- log('info', `platform ${platform.os} ${platform.os_version} ${platform.arch} shell=${platform.shell}`, {});
225
- log('info', 'caps', caps);
226
- if (detail.notes.length > 0) log('warn', 'capability probe notes', { notes: detail.notes });
227
-
228
- const agent = createAgent({
229
- rabbitUrl: baseUrl,
230
- project,
231
- stateDir,
232
- identity,
233
- // v0.6.2: the device key belongs beside the `device.json` this process actually loaded, and this
234
- // is the only place that knows where that is. Without it the agent fell back to `--state`, a
235
- // different directory — so on one machine the plugin and the agent each minted their own keypair
236
- // and then overwrote each other's announcement under the one shared machine_id.
237
- identityPath,
238
- caps,
239
- platform,
240
- allowedCommands,
241
- once: flags.once === true,
242
- onceIdleMs,
243
- heartbeatIntervalMs: heartbeatMs,
244
- operatorToken,
245
- p2pMode,
246
- p2pPort,
247
- stunServers,
248
- log,
249
- // The startup line has to name the *resolved* mode, the STUN list and whether the node
250
- // actually started. `p2p_mode` is what was asked for; `started` is what happened, and a
251
- // machine that cannot punch reports `started: false` with the reason right there -- which is
252
- // the whole point of "a failure to start the node is never fatal".
253
- onP2PStatus: (status) => {
254
- // One line, for every mode including `relay`. It is emitted when the fact is final --
255
- // the node started, the node failed to start, or there is no node to start -- so the line
256
- // never says "starting" and never repeats as the status is refreshed on each heartbeat.
257
- const final = status.running || !status.enabled || status.start_error !== null;
258
- if (p2pLogged || !final) return;
259
- p2pLogged = true;
260
- log(status.start_error === null ? 'info' : 'warn', `p2p mode=${status.mode} node ${status.running ? 'started' : 'not started'}`, {
261
- stun_servers: stunServers,
262
- ...(status.start_error === null ? {} : { error: status.start_error }),
263
- });
264
- },
265
- });
266
- if (operatorToken) {
267
- log('info', 'operator token configured (submit-capable host); the agent itself never sends it', {
268
- length: operatorToken.length,
269
- });
270
- }
271
-
272
- if (flags.pair) {
273
- const paired = await agent.pair(flags.pair);
274
- saveDeviceToken(paired.device_token, {});
275
- log('info', 'device_token stored');
276
- } else if (!identity.device_token) {
277
- log('warn', 'no device_token and no --pair: offers cannot be fetched until paired');
278
- }
279
-
280
- let stopping = false;
281
- const onSignal = (signal) => {
282
- if (stopping) return;
283
- stopping = true;
284
- log('warn', `received ${signal}, stopping`);
285
- // Awaited so the node's socket is really gone before the process leaves; an unawaited
286
- // `stop()` would let the event loop drain with a dgram handle still open.
287
- void agent.stop();
288
- };
289
- process.on('SIGINT', () => onSignal('SIGINT'));
290
- process.on('SIGTERM', () => onSignal('SIGTERM'));
291
-
292
- await agent.start();
293
- return 0;
294
- } catch (error) {
295
- process.stderr.write(`error: ${formatError(error)}\n`);
296
- return 1;
297
- }
298
- }
299
-
300
- main().then(
301
- (code) => {
302
- process.exitCode = code;
303
- },
304
- (error) => {
305
- process.stderr.write(`fatal: ${formatError(error)}\n`);
306
- process.exitCode = 1;
307
- },
308
- );
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `w2m-localside` — the Windows/macOS side of a W2M pair.
4
+ *
5
+ * node bin/w2m-localside.mjs \
6
+ * --rabbit http://127.0.0.1:8787 \
7
+ * --pair PAIR-7K2M9QX4 \
8
+ * --project E:/DS/w2m \
9
+ * --name win-desktop \
10
+ * --allowed-commands '["node --test","git status"]' \
11
+ * --state ~/.dsh/xclient/localside \
12
+ * --once
13
+ *
14
+ * The allow-list is **default-deny**: with no `--allowed-commands`, every offer
15
+ * is refused with `COMMAND_NOT_ALLOWED`. That is deliberate -- an agent that
16
+ * runs whatever it is told is not a thing this project ships.
17
+ *
18
+ * State (identity file excluded) lives under `--state`, never in the project,
19
+ * so `write: false` offers cannot dirty the worktree they are measuring.
20
+ */
21
+
22
+ import { parseArgs as parseNodeArgs } from 'node:util';
23
+ import process from 'node:process';
24
+
25
+ import { formatError, resolveStateDir } from '../src/util/cli.mjs';
26
+ import { createAgent, parseAllowedCommands } from '../src/agent/agent.mjs';
27
+ import { probeCapsDetailed, detectPlatform } from '../src/agent/caps.mjs';
28
+ import { loadOrCreateIdentity, saveDeviceToken } from '../src/agent/identity.mjs';
29
+ import { normalizeP2PMode, normalizeP2PPort, stunServersWithShared } from '../src/agent/p2p-node.mjs';
30
+ import { resolveBaseUrl } from '../src/agent/url.mjs';
31
+
32
+ const USAGE = `w2m-localside — W2M Localside agent (protocol v1)
33
+
34
+ Usage:
35
+ w2m-localside --rabbit <url> [--pair <CODE>] [--project <dir>]
36
+ [--name <name>] [--allowed-commands <json>]
37
+ [--state <dir>] [--p2p-mode <mode>] [--p2p-port <n>]
38
+ [--stun-servers <list>]
39
+ [--once] [--once-idle-ms <ms>]
40
+
41
+ Options:
42
+ --rabbit <url> Rabbit base address (default: $W2M_RABBIT_URL).
43
+ A sub-path is supported: https://host/w2m
44
+ Query strings and fragments are rejected.
45
+ --pair <CODE> One-time pairing code, e.g. PAIR-7K2M9QX4
46
+ --project <dir> Project root to run in (default: current directory)
47
+ --name <name> machine_name (default: <os>-<hostname>)
48
+ --allowed-commands <json> Default-deny allow-list, JSON array of prefixes.
49
+ Example: '["node --test","git status"]'
50
+ Nothing is allowed when omitted.
51
+ --state <dir> State directory for spool/state
52
+ (default: <DSH_HOME>/xclient/localside)
53
+ --p2p-mode <mode> auto (default) | direct | relay
54
+ (default: $W2M_P2P_MODE)
55
+ auto: accept an offer over either path
56
+ direct: refuse an offer that did not arrive
57
+ directly (P2P_UNAVAILABLE)
58
+ relay: v0.3.9 - no UDP socket is bound at all
59
+ --p2p-port <n> Local UDP port for the punch socket, 1..65535
60
+ (default: $W2M_P2P_PORT, or an ephemeral port)
61
+ Pin this on a machine that must *accept* a punch:
62
+ an ephemeral port changes on every restart, so the
63
+ firewall rule has to be re-pointed each time and
64
+ every dispatch falls back to the relay until it is.
65
+ --stun-servers <list> Comma-separated STUN servers, queried in order,
66
+ after the shared server (default: $W2M_STUN_SERVERS)
67
+ Example: stun.internal:3478,stun.l.google.com:19302
68
+ --operator-token <t> Operator token (or $W2M_OPERATOR_TOKEN). Only needed
69
+ when this host also submits tasks; the Localside
70
+ agent itself never sends it.
71
+ --once Handle one offer, then exit
72
+ --once-idle-ms <ms> With --once: give up if no offer arrives in <ms>
73
+ --heartbeat-ms <ms> Lease heartbeat interval (default: 10000)
74
+ --help Show this message
75
+
76
+ Exit codes: 0 stopped cleanly, 1 runtime error, 2 usage error.`;
77
+
78
+ /**
79
+ * Parse argv, tolerating `--flag=value` and `--flag value`.
80
+ *
81
+ * @param {string[]} argv
82
+ */
83
+ function parseCli(argv) {
84
+ const { values, positionals } = parseNodeArgs({
85
+ args: argv,
86
+ allowPositionals: false,
87
+ strict: true,
88
+ options: {
89
+ rabbit: { type: 'string' },
90
+ pair: { type: 'string' },
91
+ // v0.8.1: the account-derived key, so a machine joins its account's fleet with no
92
+ // operator step. Supplied by the plugin, which is the only process that can read the
93
+ // signed-in account; the agent is standalone and cannot derive it itself.
94
+ 'fleet-key': { type: 'string' },
95
+ project: { type: 'string' },
96
+ name: { type: 'string' },
97
+ 'allowed-commands': { type: 'string' },
98
+ 'operator-token': { type: 'string' },
99
+ state: { type: 'string' },
100
+ 'p2p-mode': { type: 'string' },
101
+ 'p2p-port': { type: 'string' },
102
+ 'stun-servers': { type: 'string' },
103
+ once: { type: 'boolean', default: false },
104
+ 'once-idle-ms': { type: 'string' },
105
+ 'heartbeat-ms': { type: 'string' },
106
+ help: { type: 'boolean', default: false },
107
+ },
108
+ });
109
+ void positionals;
110
+ return values;
111
+ }
112
+
113
+ async function main() {
114
+ let flags;
115
+ try {
116
+ flags = parseCli(process.argv.slice(2));
117
+ } catch (error) {
118
+ process.stderr.write(`${formatError(error)}\n\n${USAGE}\n`);
119
+ return 2;
120
+ }
121
+
122
+ if (flags.help) {
123
+ process.stdout.write(`${USAGE}\n`);
124
+ return 0;
125
+ }
126
+
127
+ const rabbitUrl = flags.rabbit ?? process.env.W2M_RABBIT_URL ?? '';
128
+ if (rabbitUrl.trim() === '') {
129
+ process.stderr.write('error: --rabbit <url> is required (or set W2M_RABBIT_URL)\n\n' + `${USAGE}\n`);
130
+ return 2;
131
+ }
132
+ // Validate the base address before doing anything else: a sub-path is fine,
133
+ // a query string / fragment / non-http scheme is a usage error (v0.1.2 §2).
134
+ let baseUrl;
135
+ try {
136
+ baseUrl = resolveBaseUrl(rabbitUrl);
137
+ } catch (error) {
138
+ process.stderr.write(`error: ${formatError(error)}\n`);
139
+ return 2;
140
+ }
141
+
142
+ const operatorToken = flags['operator-token'] ?? process.env.W2M_OPERATOR_TOKEN ?? null;
143
+
144
+ let allowedCommands;
145
+ try {
146
+ allowedCommands = parseAllowedCommands(flags['allowed-commands'] ?? '[]');
147
+ } catch (error) {
148
+ process.stderr.write(`error: ${formatError(error)}\n`);
149
+ return 2;
150
+ }
151
+
152
+ const project = flags.project ?? process.cwd();
153
+ const stateDir = resolveStateDir(flags.state, 'localside');
154
+ const onceIdleMs = flags['once-idle-ms'] ? Number.parseInt(flags['once-idle-ms'], 10) : 0;
155
+ const heartbeatMs = flags['heartbeat-ms']
156
+ ? Number.parseInt(flags['heartbeat-ms'], 10)
157
+ : undefined;
158
+
159
+ if (Number.isNaN(onceIdleMs) || onceIdleMs < 0) {
160
+ process.stderr.write('error: --once-idle-ms must be a non-negative integer\n');
161
+ return 2;
162
+ }
163
+ if (heartbeatMs !== undefined && (Number.isNaN(heartbeatMs) || heartbeatMs <= 0)) {
164
+ process.stderr.write('error: --heartbeat-ms must be a positive integer\n');
165
+ return 2;
166
+ }
167
+
168
+ // v0.4.0. The flag wins over the environment, and both go through the same loud validator the
169
+ // node and the agent use: a typo is a usage error (exit 2) naming the setting, exactly like
170
+ // `--rabbit` or `--once-idle-ms`, and never a silent fallback to the default. A mode that
171
+ // silently became `auto` would turn `direct` into a promise the operator only discovers was
172
+ // broken when a punch failed.
173
+ const p2pModeInput = flags['p2p-mode'] ?? process.env.W2M_P2P_MODE ?? undefined;
174
+ const p2pModeResult = normalizeP2PMode(p2pModeInput === undefined ? 'auto' : p2pModeInput);
175
+ if (!p2pModeResult.ok) {
176
+ const source = flags['p2p-mode'] !== undefined ? '--p2p-mode' : 'W2M_P2P_MODE';
177
+ process.stderr.write(`error: ${source}: ${p2pModeResult.reason}\n`);
178
+ return 2;
179
+ }
180
+ const p2pMode = p2pModeResult.mode;
181
+
182
+ // Same precedence rule, same reason, and the same loud validator the node and the agent use.
183
+ // Omitting the setting keeps the ephemeral port, which is the right default for a machine that
184
+ // only dials. A value that *is* written must be a real port: `0` is refused by name rather than
185
+ // read as "let the OS choose", because an operator who writes `0` is asking for a stable port and
186
+ // would silently get a random one -- see `normalizeP2PPort`.
187
+ const p2pPortInput = flags['p2p-port'] ?? process.env.W2M_P2P_PORT ?? undefined;
188
+ const p2pPortResult = normalizeP2PPort(p2pPortInput);
189
+ if (!p2pPortResult.ok) {
190
+ const source = flags['p2p-port'] !== undefined ? '--p2p-port' : 'W2M_P2P_PORT';
191
+ process.stderr.write(`error: ${source}: ${p2pPortResult.reason}\n`);
192
+ return 2;
193
+ }
194
+ const p2pPort = p2pPortResult.port;
195
+
196
+ // Same precedence rule, same reason. An empty list means "the shared server and the public
197
+ // fallbacks", i.e. the default -- `stunServersWithShared()` accepts '' and drops it -- so an
198
+ // accidentally blank variable is harmless rather than a node that cannot discover anything.
199
+ const stunInput = flags['stun-servers'] ?? process.env.W2M_STUN_SERVERS ?? '';
200
+ let stunServers;
201
+ try {
202
+ stunServers = stunServersWithShared(stunInput);
203
+ } catch (error) {
204
+ const source = flags['stun-servers'] !== undefined ? '--stun-servers' : 'W2M_STUN_SERVERS';
205
+ process.stderr.write(`error: ${source}: ${formatError(error)}\n`);
206
+ return 2;
207
+ }
208
+
209
+ const log = (level, message, extra) => {
210
+ const suffix = extra && Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : '';
211
+ process.stdout.write(`${new Date().toISOString()} ${level.padEnd(7)} ${message}${suffix}\n`);
212
+ };
213
+
214
+ /** One-shot guard for the resolved-P2P startup line (see `onP2PStatus` below). */
215
+ let p2pLogged = false;
216
+
217
+ try {
218
+ const { identity, path: identityPath, created } = loadOrCreateIdentity({
219
+ name: flags.name,
220
+ rabbitUrl: baseUrl,
221
+ });
222
+ log('info', `${created ? 'created' : 'loaded'} identity ${identity.machine_id} (${identityPath})`);
223
+
224
+ const [{ caps, detail }, platform] = await Promise.all([
225
+ probeCapsDetailed({}),
226
+ detectPlatform({}),
227
+ ]);
228
+ log('info', `platform ${platform.os} ${platform.os_version} ${platform.arch} shell=${platform.shell}`, {});
229
+ log('info', 'caps', caps);
230
+ if (detail.notes.length > 0) log('warn', 'capability probe notes', { notes: detail.notes });
231
+
232
+ const agent = createAgent({
233
+ rabbitUrl: baseUrl,
234
+ project,
235
+ stateDir,
236
+ identity,
237
+ // v0.6.2: the device key belongs beside the `device.json` this process actually loaded, and this
238
+ // is the only place that knows where that is. Without it the agent fell back to `--state`, a
239
+ // different directory — so on one machine the plugin and the agent each minted their own keypair
240
+ // and then overwrote each other's announcement under the one shared machine_id.
241
+ identityPath,
242
+ caps,
243
+ platform,
244
+ allowedCommands,
245
+ once: flags.once === true,
246
+ onceIdleMs,
247
+ heartbeatIntervalMs: heartbeatMs,
248
+ operatorToken,
249
+ p2pMode,
250
+ p2pPort,
251
+ stunServers,
252
+ log,
253
+ // The startup line has to name the *resolved* mode, the STUN list and whether the node
254
+ // actually started. `p2p_mode` is what was asked for; `started` is what happened, and a
255
+ // machine that cannot punch reports `started: false` with the reason right there -- which is
256
+ // the whole point of "a failure to start the node is never fatal".
257
+ onP2PStatus: (status) => {
258
+ // One line, for every mode including `relay`. It is emitted when the fact is final --
259
+ // the node started, the node failed to start, or there is no node to start -- so the line
260
+ // never says "starting" and never repeats as the status is refreshed on each heartbeat.
261
+ const final = status.running || !status.enabled || status.start_error !== null;
262
+ if (p2pLogged || !final) return;
263
+ p2pLogged = true;
264
+ log(status.start_error === null ? 'info' : 'warn', `p2p mode=${status.mode} node ${status.running ? 'started' : 'not started'}`, {
265
+ stun_servers: stunServers,
266
+ ...(status.start_error === null ? {} : { error: status.start_error }),
267
+ });
268
+ },
269
+ });
270
+ if (operatorToken) {
271
+ log('info', 'operator token configured (submit-capable host); the agent itself never sends it', {
272
+ length: operatorToken.length,
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Pair by a code, by an account fleet key, or not at all (v0.8.1).
278
+ *
279
+ * ## The bug a first version of this had
280
+ *
281
+ * The fleet-key handling was inserted **inside `if (flags.pair)`**, so the key path could only
282
+ * run when a pairing code was also supplied — which is precisely the case it exists to avoid.
283
+ * It worked whenever anyone tested it with a code, and did nothing in the case that mattered.
284
+ *
285
+ * The two are therefore peers rather than nested: either credential alone is enough.
286
+ */
287
+ const fleetKey = typeof flags['fleet-key'] === 'string' && flags['fleet-key'] !== '' ? flags['fleet-key'] : null;
288
+
289
+ if (flags.pair || fleetKey !== null) {
290
+ const paired = await agent.pair(flags.pair ?? null, undefined, fleetKey);
291
+ saveDeviceToken(paired.device_token, {});
292
+ log('info', `device_token stored (paired by ${flags.pair ? 'pairing code' : 'fleet key'})`);
293
+ } else if (!identity.device_token) {
294
+ log('warn', 'no device_token and no --pair/--fleet-key: offers cannot be fetched until paired');
295
+ }
296
+
297
+ let stopping = false;
298
+ const onSignal = (signal) => {
299
+ if (stopping) return;
300
+ stopping = true;
301
+ log('warn', `received ${signal}, stopping`);
302
+ // Awaited so the node's socket is really gone before the process leaves; an unawaited
303
+ // `stop()` would let the event loop drain with a dgram handle still open.
304
+ void agent.stop();
305
+ };
306
+ process.on('SIGINT', () => onSignal('SIGINT'));
307
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
308
+
309
+ await agent.start();
310
+ return 0;
311
+ } catch (error) {
312
+ process.stderr.write(`error: ${formatError(error)}\n`);
313
+ return 1;
314
+ }
315
+ }
316
+
317
+ main().then(
318
+ (code) => {
319
+ process.exitCode = code;
320
+ },
321
+ (error) => {
322
+ process.stderr.write(`fatal: ${formatError(error)}\n`);
323
+ process.exitCode = 1;
324
+ },
325
+ );