@rubytech/create-realagent 1.0.850 → 1.0.853

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.
@@ -0,0 +1,330 @@
1
+ // Task 938 — acceptance grid for classifyPortHolder (chromium argv-anchor).
2
+ // Task 939 — extended grid for Xtigervnc display anchor + websockify port
3
+ // anchor + holder-type detection on argv[0] basename / full-argv scan.
4
+ //
5
+ // The wrapper in index.ts owns ss(8), /proc reads, kill(2), and the operator-
6
+ // override exit. This suite exercises only the pure decision rule: ssOutput +
7
+ // brand identities → kind. Inputs in, decision out — no fs, no exec, no spawn.
8
+ //
9
+ // Cmdlines below mimic /proc/<pid>/cmdline format: argv joined by NUL bytes,
10
+ // possibly with a trailing NUL. The classifier requires this format so it
11
+ // can distinguish a flag value (a real ownership claim) from a different
12
+ // flag whose value happens to contain the marker substring.
13
+ //
14
+ // Runs via Node's built-in test runner — same convention as
15
+ // peer-brand-detect.test.ts.
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { classifyPortHolder } from "../preflight-port-classifier.js";
19
+ // Per-brand identities mirror the live brands/<brand>/brand.json values that
20
+ // bundle.js stamps into payload/platform/config/brand-registry.json. Values
21
+ // are derived from the brand manifests, not hardcoded as ground-truth here:
22
+ // the test suite asserts the rule, not the numbers. Any rebalance in
23
+ // brands/*/brand.json updates the registry on the next bundle and the
24
+ // classifier consumes the new values without source change.
25
+ const MAXY = { configDir: ".maxy", vncDisplay: 99, websockifyPort: 6080 };
26
+ const REAL = { configDir: ".realagent", vncDisplay: 100, websockifyPort: 6081 };
27
+ const MAXY_2 = { configDir: ".maxy-2", vncDisplay: 101, websockifyPort: 6082 };
28
+ const MAXY_3 = { configDir: ".maxy-3", vncDisplay: 102, websockifyPort: 6083 };
29
+ const MAXY_4 = { configDir: ".maxy-4", vncDisplay: 103, websockifyPort: 6084 };
30
+ const ALL = [MAXY, REAL, MAXY_2, MAXY_3, MAXY_4];
31
+ const peers = (own) => ALL.filter(b => b.configDir !== own.configDir);
32
+ const SS_PID_42 = "LISTEN 0 4096 *:9222 *:* users:((\"chrome\",pid=42,fd=12))";
33
+ const SS_PID_99 = "LISTEN 0 4096 *:9223 *:* users:((\"chrome\",pid=99,fd=12))";
34
+ const SS_PID_777 = "LISTEN 0 5 127.0.0.1:5900 0.0.0.0:* users:((\"Xtigervnc\",pid=777,fd=9))";
35
+ const SS_PID_888 = "LISTEN 0 5 127.0.0.1:5901 0.0.0.0:* users:((\"Xtigervnc\",pid=888,fd=9))";
36
+ const SS_PID_555 = "LISTEN 0 5 *:6080 *:* users:((\"websockify\",pid=555,fd=8))";
37
+ const cmd = (...argv) => argv.join("\0") + "\0";
38
+ // ---------------------------------------------------------------------------
39
+ // EMPTY / no pid / cmdline race
40
+ // ---------------------------------------------------------------------------
41
+ test("empty ssOutput → EMPTY", () => {
42
+ const r = classifyPortHolder({
43
+ ssOutput: "",
44
+ ownBrand: MAXY,
45
+ peerBrands: peers(MAXY),
46
+ getCmdline: () => { throw new Error("should not be called"); },
47
+ });
48
+ assert.equal(r.kind, "EMPTY");
49
+ });
50
+ test("UNRELATED: ssOutput non-empty but no pid= token", () => {
51
+ const r = classifyPortHolder({
52
+ ssOutput: "Recv-Q Send-Q Local Address:Port Peer Address:Port",
53
+ ownBrand: MAXY,
54
+ peerBrands: peers(MAXY),
55
+ getCmdline: () => { throw new Error("should not be called"); },
56
+ });
57
+ assert.equal(r.kind, "UNRELATED");
58
+ assert.equal(r.pid, undefined);
59
+ });
60
+ test("UNRELATED: getCmdline throws (race — process exited between ss and read)", () => {
61
+ const r = classifyPortHolder({
62
+ ssOutput: SS_PID_42,
63
+ ownBrand: MAXY,
64
+ peerBrands: peers(MAXY),
65
+ getCmdline: () => { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; },
66
+ });
67
+ assert.equal(r.kind, "UNRELATED");
68
+ assert.equal(r.pid, 42);
69
+ assert.equal(r.cmdlineReadFailed, true);
70
+ });
71
+ test("ss header line containing 'pid=': last pid= wins", () => {
72
+ // If a future ss locale prepends header text containing the literal `pid=`,
73
+ // the first-match heuristic would lift the wrong value. The LISTEN row is
74
+ // always last, so taking the last pid= match is the structural fix.
75
+ const ssOutput = `State pid= notes\nLISTEN 0 4096 *:9222 *:* users:(("chrome",pid=42,fd=12))`;
76
+ const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
77
+ const r = classifyPortHolder({
78
+ ssOutput,
79
+ ownBrand: MAXY,
80
+ peerBrands: peers(MAXY),
81
+ getCmdline: pid => { assert.equal(pid, 42, "must pick the LISTEN row's pid"); return cmdline; },
82
+ });
83
+ assert.equal(r.kind, "OWN_BRAND");
84
+ assert.equal(r.pid, 42);
85
+ });
86
+ // ---------------------------------------------------------------------------
87
+ // Chromium (Task 938 grid, migrated to BrandIdentity API)
88
+ // ---------------------------------------------------------------------------
89
+ test("chromium OWN_BRAND: --user-data-dir=PATH single argv", () => {
90
+ const cmdline = cmd("/opt/google/chrome/chrome", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
91
+ const r = classifyPortHolder({
92
+ ssOutput: SS_PID_42,
93
+ ownBrand: MAXY,
94
+ peerBrands: peers(MAXY),
95
+ getCmdline: pid => { assert.equal(pid, 42); return cmdline; },
96
+ });
97
+ assert.equal(r.kind, "OWN_BRAND");
98
+ assert.equal(r.holderType, "chromium");
99
+ assert.equal(r.pid, 42);
100
+ assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
101
+ });
102
+ test("chromium OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
103
+ const cmdline = cmd("/usr/bin/chromium", "--user-data-dir", "/home/admin/.maxy/chromium-profile", "--remote-debugging-port=9222");
104
+ const r = classifyPortHolder({
105
+ ssOutput: SS_PID_42,
106
+ ownBrand: MAXY,
107
+ peerBrands: peers(MAXY),
108
+ getCmdline: () => cmdline,
109
+ });
110
+ assert.equal(r.kind, "OWN_BRAND");
111
+ assert.equal(r.holderType, "chromium");
112
+ assert.equal(r.profilePath, "/home/admin/.maxy/chromium-profile");
113
+ });
114
+ test("chromium PEER_BRAND: cmdline holds another known brand's profile path", () => {
115
+ const cmdline = cmd("/usr/bin/chromium", "--user-data-dir=/home/admin/.realagent/chromium-profile", "--remote-debugging-port=9223");
116
+ const r = classifyPortHolder({
117
+ ssOutput: SS_PID_99,
118
+ ownBrand: MAXY,
119
+ peerBrands: peers(MAXY),
120
+ getCmdline: () => cmdline,
121
+ });
122
+ assert.equal(r.kind, "PEER_BRAND");
123
+ assert.equal(r.holderType, "chromium");
124
+ assert.equal(r.pid, 99);
125
+ assert.equal(r.profilePath, "/home/admin/.realagent/chromium-profile");
126
+ });
127
+ test("chromium UNRELATED: cmdline matches no known brand profile", () => {
128
+ // Anchors only on the basename — "/usr/bin/python3" is not chromium.
129
+ // Use an actual chromium binary with a non-brand profile path instead.
130
+ const cmdline = cmd("chromium", "--user-data-dir=/tmp/scratch", "--remote-debugging-port=9999");
131
+ const r = classifyPortHolder({
132
+ ssOutput: SS_PID_42,
133
+ ownBrand: MAXY,
134
+ peerBrands: peers(MAXY),
135
+ getCmdline: () => cmdline,
136
+ });
137
+ assert.equal(r.kind, "UNRELATED");
138
+ assert.equal(r.holderType, "chromium");
139
+ assert.equal(r.pid, 42);
140
+ });
141
+ test("chromium UNRELATED: --user-data-dir absent (no profile claim)", () => {
142
+ // Kernel threads, GPU/zygote/utility chrome processes inherit profile via
143
+ // fork without re-stating --user-data-dir. They wouldn't be listening
144
+ // anyway, but if one ever showed up here we must classify as UNRELATED.
145
+ const cmdline = cmd("/opt/google/chrome/chrome", "--type=gpu-process", "--no-sandbox");
146
+ const r = classifyPortHolder({
147
+ ssOutput: SS_PID_42,
148
+ ownBrand: MAXY,
149
+ peerBrands: peers(MAXY),
150
+ getCmdline: () => cmdline,
151
+ });
152
+ assert.equal(r.kind, "UNRELATED");
153
+ assert.equal(r.holderType, "chromium");
154
+ });
155
+ test("chromium substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
156
+ const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy-2/chromium-profile");
157
+ const r = classifyPortHolder({
158
+ ssOutput: SS_PID_42,
159
+ ownBrand: MAXY,
160
+ peerBrands: peers(MAXY),
161
+ getCmdline: () => cmdline,
162
+ });
163
+ assert.equal(r.kind, "PEER_BRAND");
164
+ assert.equal(r.profilePath, "/home/neo/.maxy-2/chromium-profile");
165
+ });
166
+ test("chromium substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
167
+ const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
168
+ const r = classifyPortHolder({
169
+ ssOutput: SS_PID_42,
170
+ ownBrand: MAXY_2,
171
+ peerBrands: peers(MAXY_2),
172
+ getCmdline: () => cmdline,
173
+ });
174
+ assert.equal(r.kind, "PEER_BRAND");
175
+ assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
176
+ });
177
+ test("chromium argv-anchor safety: marker in --load-extension value must NOT match", () => {
178
+ // Adversarial case: a Chrome flag whose value contains the profile-path
179
+ // substring, but no actual --user-data-dir claim. Naive substring matching
180
+ // would false-positive OWN_BRAND.
181
+ const cmdline = cmd("chromium", "--load-extension=/tmp/decoy/.maxy/chromium-profile", "--remote-debugging-port=9222");
182
+ const r = classifyPortHolder({
183
+ ssOutput: SS_PID_42,
184
+ ownBrand: MAXY,
185
+ peerBrands: peers(MAXY),
186
+ getCmdline: () => cmdline,
187
+ });
188
+ assert.equal(r.kind, "UNRELATED");
189
+ });
190
+ test("chromium variant basenames: google-chrome-stable detected as chromium", () => {
191
+ // Ubuntu Noble laptop replaces snap-confined chromium with
192
+ // google-chrome-stable (Task 929). The classifier must still recognise
193
+ // it as a chromium-family holder.
194
+ const cmdline = cmd("/usr/bin/google-chrome-stable", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
195
+ const r = classifyPortHolder({
196
+ ssOutput: SS_PID_42,
197
+ ownBrand: MAXY,
198
+ peerBrands: peers(MAXY),
199
+ getCmdline: () => cmdline,
200
+ });
201
+ assert.equal(r.kind, "OWN_BRAND");
202
+ assert.equal(r.holderType, "chromium");
203
+ });
204
+ // ---------------------------------------------------------------------------
205
+ // Xtigervnc (Task 939 — display-number anchor)
206
+ // ---------------------------------------------------------------------------
207
+ test("xtigervnc OWN_BRAND: argv[1] :99 matches own.vncDisplay", () => {
208
+ // Reproduces the laptop-maxy and Pi-maxy install-time log:
209
+ // cmdline: Xtigervnc :99 -geometry 1280x800 -depth 24 -rfbport 5900 …
210
+ const cmdline = cmd("Xtigervnc", ":99", "-geometry", "1280x800", "-depth", "24", "-rfbport", "5900", "-localhost", "-SecurityTypes", "None", "-AlwaysShared", "-BlacklistThreshold", "1000000");
211
+ const r = classifyPortHolder({
212
+ ssOutput: SS_PID_777,
213
+ ownBrand: MAXY,
214
+ peerBrands: peers(MAXY),
215
+ getCmdline: pid => { assert.equal(pid, 777); return cmdline; },
216
+ });
217
+ assert.equal(r.kind, "OWN_BRAND");
218
+ assert.equal(r.holderType, "xtigervnc");
219
+ assert.equal(r.pid, 777);
220
+ assert.equal(r.vncDisplay, 99);
221
+ });
222
+ test("xtigervnc PEER_BRAND: argv[1] :100 matches Real Agent vncDisplay when own is Maxy", () => {
223
+ const cmdline = cmd("Xtigervnc", ":100", "-rfbport", "5901");
224
+ const r = classifyPortHolder({
225
+ ssOutput: SS_PID_888,
226
+ ownBrand: MAXY,
227
+ peerBrands: peers(MAXY),
228
+ getCmdline: () => cmdline,
229
+ });
230
+ assert.equal(r.kind, "PEER_BRAND");
231
+ assert.equal(r.holderType, "xtigervnc");
232
+ assert.equal(r.vncDisplay, 100);
233
+ });
234
+ test("xtigervnc UNRELATED: argv display number matches no known brand", () => {
235
+ const cmdline = cmd("Xtigervnc", ":42", "-rfbport", "5942");
236
+ const r = classifyPortHolder({
237
+ ssOutput: SS_PID_777,
238
+ ownBrand: MAXY,
239
+ peerBrands: peers(MAXY),
240
+ getCmdline: () => cmdline,
241
+ });
242
+ assert.equal(r.kind, "UNRELATED");
243
+ assert.equal(r.holderType, "xtigervnc");
244
+ assert.equal(r.vncDisplay, 42);
245
+ });
246
+ test("xtigervnc UNRELATED: no `:N` token in argv", () => {
247
+ // Defence-in-depth: if argv parsing ever sees an Xtigervnc invocation
248
+ // with no display literal, classify UNRELATED rather than guessing.
249
+ const cmdline = cmd("Xtigervnc", "-help");
250
+ const r = classifyPortHolder({
251
+ ssOutput: SS_PID_777,
252
+ ownBrand: MAXY,
253
+ peerBrands: peers(MAXY),
254
+ getCmdline: () => cmdline,
255
+ });
256
+ assert.equal(r.kind, "UNRELATED");
257
+ assert.equal(r.holderType, "xtigervnc");
258
+ assert.equal(r.vncDisplay, undefined);
259
+ });
260
+ // ---------------------------------------------------------------------------
261
+ // websockify (Task 939 — bind-port anchor with full-argv scan for the binary)
262
+ // ---------------------------------------------------------------------------
263
+ test("websockify OWN_BRAND: bind port [::]:6080 matches own.websockifyPort (direct invocation)", () => {
264
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:6080", "localhost:5900");
265
+ const r = classifyPortHolder({
266
+ ssOutput: SS_PID_555,
267
+ ownBrand: MAXY,
268
+ peerBrands: peers(MAXY),
269
+ getCmdline: pid => { assert.equal(pid, 555); return cmdline; },
270
+ });
271
+ assert.equal(r.kind, "OWN_BRAND");
272
+ assert.equal(r.holderType, "websockify");
273
+ assert.equal(r.pid, 555);
274
+ assert.equal(r.websockifyPort, 6080);
275
+ });
276
+ test("websockify OWN_BRAND: detected via argv scan when invoked through python", () => {
277
+ // Pi Bookworm packages websockify as a python script with no shebang
278
+ // honoured by systemd in some configs — argv[0] may be `python3` and
279
+ // argv[1] = `/usr/bin/websockify`. The detector scans the full argv.
280
+ const cmdline = cmd("/usr/bin/python3", "/usr/bin/websockify", "--web", "/usr/share/novnc", "[::]:6080", "localhost:5900");
281
+ const r = classifyPortHolder({
282
+ ssOutput: SS_PID_555,
283
+ ownBrand: MAXY,
284
+ peerBrands: peers(MAXY),
285
+ getCmdline: () => cmdline,
286
+ });
287
+ assert.equal(r.kind, "OWN_BRAND");
288
+ assert.equal(r.holderType, "websockify");
289
+ assert.equal(r.websockifyPort, 6080);
290
+ });
291
+ test("websockify PEER_BRAND: bind port matches Real Agent websockifyPort when own is Maxy", () => {
292
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:6081", "localhost:5901");
293
+ const r = classifyPortHolder({
294
+ ssOutput: SS_PID_555,
295
+ ownBrand: MAXY,
296
+ peerBrands: peers(MAXY),
297
+ getCmdline: () => cmdline,
298
+ });
299
+ assert.equal(r.kind, "PEER_BRAND");
300
+ assert.equal(r.holderType, "websockify");
301
+ assert.equal(r.websockifyPort, 6081);
302
+ });
303
+ test("websockify UNRELATED: bind port matches no known brand", () => {
304
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:9999", "localhost:5900");
305
+ const r = classifyPortHolder({
306
+ ssOutput: SS_PID_555,
307
+ ownBrand: MAXY,
308
+ peerBrands: peers(MAXY),
309
+ getCmdline: () => cmdline,
310
+ });
311
+ assert.equal(r.kind, "UNRELATED");
312
+ assert.equal(r.holderType, "websockify");
313
+ assert.equal(r.websockifyPort, undefined);
314
+ });
315
+ // ---------------------------------------------------------------------------
316
+ // Unknown holder (defence-in-depth)
317
+ // ---------------------------------------------------------------------------
318
+ test("UNRELATED holderType=unknown: argv[0] is neither chromium, Xtigervnc, nor websockify", () => {
319
+ const cmdline = cmd("/usr/bin/python3", "-m", "http.server", "9222");
320
+ const r = classifyPortHolder({
321
+ ssOutput: SS_PID_42,
322
+ ownBrand: MAXY,
323
+ peerBrands: peers(MAXY),
324
+ getCmdline: () => cmdline,
325
+ });
326
+ assert.equal(r.kind, "UNRELATED");
327
+ assert.equal(r.holderType, "unknown");
328
+ assert.equal(r.pid, 42);
329
+ assert.equal(r.cmdline, "/usr/bin/python3 -m http.server 9222");
330
+ });
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { renderPlist } from "./launchd-plist.js";
11
11
  import { installAllBrewPackages } from "./brew-install.js";
12
12
  import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
13
13
  import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
14
+ import { classifyPortHolder } from "./preflight-port-classifier.js";
14
15
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
15
16
  // Brand manifest — read from payload to derive all brand-specific installation values.
16
17
  // The bundler stamps brand.json into the payload at build time.
@@ -2452,39 +2453,189 @@ function installService() {
2452
2453
  const RFB_PORT = BRAND.rfbPort ?? 5900 + VNC_OFFSET;
2453
2454
  const WEBSOCKIFY_PORT_BRAND = BRAND.websockifyPort ?? 6080 + VNC_OFFSET;
2454
2455
  const CDP_PORT_BRAND = BRAND.cdpPort ?? 9222 + VNC_OFFSET;
2455
- // Task 924 pre-flight — refuse to write service files if any of the
2456
- // three brand-scoped ports is already held by a process that is NOT a
2457
- // peer brand's edge service. Collisions with peer brands' Xtigervnc or
2458
- // websockify on this device are normal in concurrent multi-brand
2459
- // installs and harmless because each brand's port set is disjoint by
2460
- // construction. Anything else (a stale pre-924 unit, an external VNC,
2461
- // or operator misconfiguration) is a structural collision the operator
2462
- // must resolve before service start.
2463
- const checkInstallPortFree = (label, port) => {
2456
+ // Task 924/938 pre-flight — refuse to write service files if any of the
2457
+ // three brand-scoped ports is already held by a process that is NOT this
2458
+ // brand's own on-demand browser nor a peer brand's edge stack.
2459
+ //
2460
+ // Classification (Task 938 chromium, Task 939 Xtigervnc + websockify) reads
2461
+ // `/proc/<pid>/cmdline` and applies a holder-specific argv anchor. Task 938
2462
+ // covered chromium-only via `--user-data-dir=`; Task 939 closed the gap on
2463
+ // Xtigervnc (no such flag — anchor on the `:N` display literal) and
2464
+ // websockify (anchor on bind port). Brand identities (configDir,
2465
+ // vncDisplay, websockifyPort) come from brand-registry.json which the
2466
+ // bundler stamps at build time from every brands/<brand>/brand.json.
2467
+ //
2468
+ // Decisions per holder:
2469
+ // OWN_BRAND — SIGTERM, recheck, SIGKILL on stragglers, exit-1 only if
2470
+ // the port is still held after both signals.
2471
+ // PEER_BRAND — log OK and return (per-brand port sets are disjoint).
2472
+ // UNRELATED — refuse to write service files; emit operator override.
2473
+ // macOS dev hosts (no ss) fall through the catch and skip pre-flight
2474
+ // entirely — the runtime check in vnc.sh covers Linux production.
2475
+ const ownBrand = {
2476
+ configDir: BRAND.configDir,
2477
+ vncDisplay: VNC_DISPLAY,
2478
+ websockifyPort: WEBSOCKIFY_PORT_BRAND,
2479
+ };
2480
+ // Peer registry — load from payload/platform/config/brand-registry.json
2481
+ // when present (Task 939+ bundles). Older bundles ship without the
2482
+ // registry; in that case peerBrands stays empty. PEER_BRAND classification
2483
+ // for Xtigervnc/websockify is a defence-in-depth case anyway (port sets
2484
+ // are disjoint by Task 924), so the empty-list fallback is safe — peer
2485
+ // chromium will fall through to UNRELATED, matching pre-Task 938 behaviour
2486
+ // for the only realistic scenario (a stale peer browser on the wrong CDP
2487
+ // port).
2488
+ const peerBrands = (() => {
2489
+ const registryPath = join(PAYLOAD_DIR, "platform", "config", "brand-registry.json");
2490
+ if (!existsSync(registryPath)) {
2491
+ logFile(` [preflight] brand-registry.json not in payload — peer matching disabled`);
2492
+ return [];
2493
+ }
2464
2494
  try {
2465
- const out = execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
2466
- encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
2467
- });
2468
- const heldBy = out.trim();
2469
- if (!heldBy)
2470
- return;
2471
- const isPeerBrandStack = /Xtigervnc/.test(heldBy) || /websockify/.test(heldBy) || /chromium/.test(heldBy);
2472
- if (isPeerBrandStack) {
2473
- logFile(` [preflight] ${label}=${port} held by a peer brand's stack — OK (per-brand ports are disjoint by construction)`);
2474
- return;
2495
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
2496
+ const entries = [];
2497
+ for (const b of raw.brands ?? []) {
2498
+ if (b.hostname === BRAND.hostname)
2499
+ continue;
2500
+ if (typeof b.configDir !== "string" || typeof b.vncDisplay !== "number" || typeof b.websockifyPort !== "number")
2501
+ continue;
2502
+ entries.push({ configDir: b.configDir, vncDisplay: b.vncDisplay, websockifyPort: b.websockifyPort });
2475
2503
  }
2476
- console.error(` ERROR: [preflight:collision] brand=${BRAND.hostname} ${label}=${port} held by an unrelated process:`);
2477
- console.error(` ${heldBy}`);
2478
- console.error(` Refusing to write service files; resolve the collision before retrying.`);
2479
- console.error(` Operator override: edit brands/${BRAND.hostname}/brand.json and set ${label}=<free port>, re-bundle, re-install.`);
2504
+ return entries;
2505
+ }
2506
+ catch (err) {
2507
+ logFile(` [preflight] brand-registry.json parse failed: ${err instanceof Error ? err.message : String(err)} peer matching disabled`);
2508
+ return [];
2509
+ }
2510
+ })();
2511
+ const ssReadHolder = (port) => {
2512
+ return execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
2513
+ encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
2514
+ });
2515
+ };
2516
+ // Pass raw NUL-separated cmdline to the classifier so it can argv-anchor
2517
+ // on `--user-data-dir=`. Replacing NUL with space here would defeat that.
2518
+ const readCmdline = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf-8");
2519
+ const sleepMs = (ms) => { spawnSync("sleep", [(ms / 1000).toString()]); };
2520
+ // Tightly scoped variant for retry-path ss reads. Failures here (timeout,
2521
+ // ENOMEM, signal) are structural — never the macOS-no-ss case (we already
2522
+ // succeeded once) — so they get a structured exit, not a stack trace.
2523
+ const ssReadOrAbort = (label, port) => {
2524
+ try {
2525
+ return ssReadHolder(port);
2526
+ }
2527
+ catch (err) {
2528
+ console.error(` ERROR: [preflight] ${label}=${port} ss recheck failed: ${err instanceof Error ? err.message : String(err)}`);
2529
+ console.error(` Resolve manually before retrying.`);
2480
2530
  process.exit(1);
2481
2531
  }
2532
+ };
2533
+ // Distinguish ESRCH (process already gone — expected) from EPERM/EINVAL
2534
+ // (alarming — signals we can't deliver, possibly a recycled pid). Returns
2535
+ // true for clean kill or ESRCH, false otherwise (caller logs a warning).
2536
+ const killNoThrow = (pid, signal) => {
2537
+ try {
2538
+ process.kill(pid, signal);
2539
+ return true;
2540
+ }
2541
+ catch (err) {
2542
+ const code = err.code;
2543
+ if (code === "ESRCH")
2544
+ return true;
2545
+ logFile(` [preflight] kill(${pid}, ${signal}) failed code=${code ?? "unknown"}`);
2546
+ return false;
2547
+ }
2548
+ };
2549
+ const classify = (ssOutput) => classifyPortHolder({
2550
+ ssOutput, ownBrand, peerBrands, getCmdline: readCmdline,
2551
+ });
2552
+ // Task 939 — log line varies by detected holder so the operator can see
2553
+ // which OWN_BRAND stack is being killed. The kill loop is identical for
2554
+ // all three holders (SIGTERM → 300ms → recheck → SIGKILL → recheck), so
2555
+ // only the announce line differs.
2556
+ const ownBrandAnnounceLine = (label, port, c) => {
2557
+ if (c.holderType === "xtigervnc") {
2558
+ return ` [preflight] ${label}=${port} held by OWN brand Xtigervnc display=:${c.vncDisplay} pid=${c.pid} — sending SIGTERM`;
2559
+ }
2560
+ if (c.holderType === "websockify") {
2561
+ return ` [preflight] ${label}=${port} held by OWN brand websockify pid=${c.pid} — sending SIGTERM`;
2562
+ }
2563
+ // Default — chromium / unknown OWN_BRAND
2564
+ return ` [preflight] ${label}=${port} held by OWN brand process pid=${c.pid} profile=${c.profilePath} — sending SIGTERM`;
2565
+ };
2566
+ const checkInstallPortFree = (label, port) => {
2567
+ let firstSsOutput;
2568
+ try {
2569
+ firstSsOutput = ssReadHolder(port);
2570
+ }
2482
2571
  catch (err) {
2483
2572
  // ss may not be present on macOS dev hosts — skip the pre-flight there
2484
2573
  // rather than abort the install. The runtime check in vnc.sh covers
2485
- // production-like Linux installs where this matters.
2574
+ // production-like Linux installs where this matters. This catch is
2575
+ // narrow on purpose: only the first ss invocation may legitimately
2576
+ // fail (binary missing); retry-path failures use ssReadOrAbort.
2486
2577
  logFile(` [preflight] ${label}=${port} check skipped: ${err instanceof Error ? err.message : String(err)}`);
2578
+ return;
2579
+ }
2580
+ let r = classify(firstSsOutput);
2581
+ // ENOENT race — process exited between ss and cmdline read. Port is
2582
+ // probably free now; one re-check resolves it deterministically.
2583
+ if (r.cmdlineReadFailed)
2584
+ r = classify(ssReadOrAbort(label, port));
2585
+ if (r.kind === "EMPTY")
2586
+ return;
2587
+ if (r.kind === "PEER_BRAND") {
2588
+ logFile(` [preflight] ${label}=${port} held by a peer brand's stack — OK (per-brand ports are disjoint by construction)`);
2589
+ return;
2487
2590
  }
2591
+ if (r.kind === "OWN_BRAND" && r.pid !== undefined) {
2592
+ logFile(ownBrandAnnounceLine(label, port, r));
2593
+ killNoThrow(r.pid, "SIGTERM");
2594
+ sleepMs(300);
2595
+ const after = classify(ssReadOrAbort(label, port));
2596
+ if (after.kind === "EMPTY") {
2597
+ logFile(` [preflight] ${label}=${port} freed`);
2598
+ return;
2599
+ }
2600
+ if (after.kind === "OWN_BRAND" && after.pid === r.pid) {
2601
+ logFile(` [preflight] ${label}=${port} survived SIGTERM — sending SIGKILL`);
2602
+ killNoThrow(r.pid, "SIGKILL");
2603
+ sleepMs(300);
2604
+ const final = classify(ssReadOrAbort(label, port));
2605
+ if (final.kind === "EMPTY") {
2606
+ logFile(` [preflight] ${label}=${port} freed`);
2607
+ return;
2608
+ }
2609
+ console.error(` ERROR: [preflight] ${label}=${port} OWN_BRAND auto-kill failed pid=${r.pid} — resolve manually before retrying.`);
2610
+ process.exit(1);
2611
+ }
2612
+ // A different OWN_BRAND pid took the port. The brand's user services
2613
+ // are respawning Chromium — installer cannot win this race. Stop the
2614
+ // services, then retry.
2615
+ if (after.kind === "OWN_BRAND") {
2616
+ console.error(` ERROR: [preflight] ${label}=${port} brand respawned a new OWN_BRAND pid (was ${r.pid}, now ${after.pid}).`);
2617
+ console.error(` Stop the brand's user services first: \`systemctl --user stop ${BRAND.hostname}-edge ${BRAND.hostname}\`, then re-run the installer.`);
2618
+ process.exit(1);
2619
+ }
2620
+ // PEER_BRAND or UNRELATED took the port post-kill — fall through to
2621
+ // those branches by re-classifying the surviving holder.
2622
+ r = after;
2623
+ if (r.kind === "EMPTY")
2624
+ return;
2625
+ if (r.kind === "PEER_BRAND") {
2626
+ logFile(` [preflight] ${label}=${port} now held by a peer brand's stack — OK`);
2627
+ return;
2628
+ }
2629
+ // r.kind === "UNRELATED" — fall through to the operator-override block.
2630
+ }
2631
+ // UNRELATED — preserve the operator-override path verbatim.
2632
+ console.error(` ERROR: [preflight:collision] brand=${BRAND.hostname} ${label}=${port} held by an unrelated process:`);
2633
+ console.error(` ${firstSsOutput.trim()}`);
2634
+ if (r.cmdline)
2635
+ console.error(` cmdline: ${r.cmdline}`);
2636
+ console.error(` Refusing to write service files; resolve the collision before retrying.`);
2637
+ console.error(` Operator override: edit brands/${BRAND.hostname}/brand.json, set/add \`${label}\` to a free port, re-bundle, re-install.`);
2638
+ process.exit(1);
2488
2639
  };
2489
2640
  checkInstallPortFree("rfbPort", RFB_PORT);
2490
2641
  checkInstallPortFree("websockifyPort", WEBSOCKIFY_PORT_BRAND);
@@ -2631,8 +2782,8 @@ WantedBy=multi-user.target
2631
2782
  console.log(" [cdp-check] skipped reason=native-display (on-demand Chromium)");
2632
2783
  }
2633
2784
  else {
2634
- console.log(" Verifying browser automation (CDP on port 9222)...");
2635
- const cdpCheck = spawnSync("curl", ["-sf", "http://127.0.0.1:9222/json/version", "-o", "/dev/null"], {
2785
+ console.log(` Verifying browser automation (CDP on port ${CDP_PORT_BRAND})...`);
2786
+ const cdpCheck = spawnSync("curl", ["-sf", `http://127.0.0.1:${CDP_PORT_BRAND}/json/version`, "-o", "/dev/null"], {
2636
2787
  timeout: 5000,
2637
2788
  stdio: "pipe",
2638
2789
  });
@@ -2649,8 +2800,8 @@ WantedBy=multi-user.target
2649
2800
  vncLog = `(no boot log found at ${vncLogPath})`;
2650
2801
  }
2651
2802
  console.error("");
2652
- console.error("Setup failed: Browser automation unavailable — CDP port 9222 not responding");
2653
- console.error(" ERROR: Browser automation unavailable — CDP port 9222 not responding.");
2803
+ console.error(`Setup failed: Browser automation unavailable — CDP port ${CDP_PORT_BRAND} not responding`);
2804
+ console.error(` ERROR: Browser automation unavailable — CDP port ${CDP_PORT_BRAND} not responding.`);
2654
2805
  console.error(" Chromium should be started by vnc.sh (ExecStartPre). Check the boot log:");
2655
2806
  console.error("");
2656
2807
  console.error(vncLog);
@@ -0,0 +1,222 @@
1
+ // Task 938 — pure classifier for the install-time port collision pre-flight.
2
+ // Task 939 — extended with Xtigervnc and websockify holder anchors, closing
3
+ // the laptop + Pi miss where the brand's own VNC stack was misclassified
4
+ // UNRELATED because Xtigervnc has no `--user-data-dir=` argv to anchor on.
5
+ //
6
+ // The wrapper in index.ts owns the side effects: ss read, /proc/<pid>/cmdline
7
+ // read, SIGTERM/SIGKILL escalation, and the operator-override exit. This
8
+ // module owns only the classification rule — inputs in, decision out.
9
+ //
10
+ // Holder detection (post-pid):
11
+ // argv[0] basename ∈ chromium-set → chromium
12
+ // argv[0] basename === "Xtigervnc" → xtigervnc
13
+ // any argv basename === "websockify" (full scan → websockify
14
+ // because Pi Bookworm invokes it as
15
+ // `python3 /usr/bin/websockify …`, so argv[0] is
16
+ // the interpreter, not websockify itself)
17
+ // else → unknown
18
+ //
19
+ // Per-holder anchor:
20
+ // chromium — `--user-data-dir=PATH`; PATH contains `/<configDir>/chromium-profile`
21
+ // → OWN_BRAND if configDir matches own; PEER_BRAND if matches peer.
22
+ // False-match guard: argv-anchor on the flag, never substring,
23
+ // so `--load-extension=…/<configDir>/…` cannot poison.
24
+ // xtigervnc — first non-flag argv of the form `:N` (display number);
25
+ // vnc.sh invokes `Xtigervnc "${VNC_DISPLAY}" -rfbport …` so
26
+ // argv[1] is literally `:99`, `:100`, etc. Match N against
27
+ // own.vncDisplay / peer.vncDisplay.
28
+ // websockify — collect every port number embedded in argv (covers both
29
+ // `[::]:6080` bind form and `localhost:5900` upstream form).
30
+ // Per Task 924, brand websockifyPort/rfbPort sets are
31
+ // disjoint, so at most one brand's websockifyPort can appear.
32
+ //
33
+ // Why "last pid=" instead of "first pid=": ss with `-tlnpH sport = :PORT`
34
+ // emits the LISTEN row last; if a future ss locale prepends a header line
35
+ // containing `pid=` text, the first-match heuristic would lift the wrong
36
+ // pid. The last `pid=` is always inside the LISTEN row's `users:((…))`.
37
+ /**
38
+ * `cmdline` should be the raw `/proc/<pid>/cmdline` contents — NUL-separated
39
+ * argv as the kernel emits it. Do NOT replace NUL with space before passing:
40
+ * the argv boundaries are what disambiguate `--user-data-dir=PATH` from a
41
+ * different flag whose value happens to contain `--user-data-dir=`.
42
+ */
43
+ export function classifyPortHolder(args) {
44
+ const { ssOutput, ownBrand, peerBrands, getCmdline } = args;
45
+ if (ssOutput.trim() === "")
46
+ return { kind: "EMPTY" };
47
+ const pidMatches = [...ssOutput.matchAll(/pid=(\d+)/g)];
48
+ if (pidMatches.length === 0)
49
+ return { kind: "UNRELATED" };
50
+ const pid = Number(pidMatches[pidMatches.length - 1][1]);
51
+ let cmdline;
52
+ try {
53
+ cmdline = getCmdline(pid);
54
+ }
55
+ catch {
56
+ return { kind: "UNRELATED", pid, cmdlineReadFailed: true };
57
+ }
58
+ const prettyCmdline = cmdline.replace(/\0/g, " ").trim();
59
+ const argv = cmdline.split("\0").filter(s => s.length > 0);
60
+ const holderType = detectHolderType(argv);
61
+ switch (holderType) {
62
+ case "chromium":
63
+ return classifyChromium(argv, prettyCmdline, pid, ownBrand, peerBrands);
64
+ case "xtigervnc":
65
+ return classifyXtigervnc(argv, prettyCmdline, pid, ownBrand, peerBrands);
66
+ case "websockify":
67
+ return classifyWebsockify(argv, prettyCmdline, pid, ownBrand, peerBrands);
68
+ case "unknown":
69
+ return { kind: "UNRELATED", pid, cmdline: prettyCmdline, holderType };
70
+ }
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Holder detection
74
+ // ---------------------------------------------------------------------------
75
+ const CHROMIUM_BASENAMES = new Set([
76
+ "chrome",
77
+ "chromium",
78
+ "chromium-browser",
79
+ "google-chrome",
80
+ "google-chrome-stable",
81
+ ]);
82
+ function basename(p) {
83
+ const i = p.lastIndexOf("/");
84
+ return i === -1 ? p : p.slice(i + 1);
85
+ }
86
+ function detectHolderType(argv) {
87
+ if (argv.length === 0)
88
+ return "unknown";
89
+ const head = basename(argv[0]);
90
+ if (CHROMIUM_BASENAMES.has(head))
91
+ return "chromium";
92
+ if (head === "Xtigervnc")
93
+ return "xtigervnc";
94
+ // websockify can be invoked directly (shebang) or via python; scan argv.
95
+ for (const a of argv) {
96
+ if (basename(a) === "websockify")
97
+ return "websockify";
98
+ }
99
+ return "unknown";
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Per-holder anchors
103
+ // ---------------------------------------------------------------------------
104
+ function classifyChromium(argv, prettyCmdline, pid, ownBrand, peerBrands) {
105
+ const userDataDir = findFlagValue(argv, "--user-data-dir");
106
+ if (userDataDir === null) {
107
+ // Kernel threads, GPU/zygote/utility chrome processes inherit profile
108
+ // via fork without re-stating --user-data-dir. They wouldn't be
109
+ // listening anyway, but classify UNRELATED if one ever shows up here
110
+ // — never OWN_BRAND on a substring fluke.
111
+ return { kind: "UNRELATED", pid, cmdline: prettyCmdline, holderType: "chromium" };
112
+ }
113
+ const ownSuffix = `/${ownBrand.configDir}/chromium-profile`;
114
+ if (userDataDir.includes(ownSuffix)) {
115
+ return {
116
+ kind: "OWN_BRAND", pid, cmdline: prettyCmdline,
117
+ profilePath: userDataDir, holderType: "chromium",
118
+ };
119
+ }
120
+ for (const peer of peerBrands) {
121
+ const peerSuffix = `/${peer.configDir}/chromium-profile`;
122
+ if (userDataDir.includes(peerSuffix)) {
123
+ return {
124
+ kind: "PEER_BRAND", pid, cmdline: prettyCmdline,
125
+ profilePath: userDataDir, holderType: "chromium",
126
+ };
127
+ }
128
+ }
129
+ return { kind: "UNRELATED", pid, cmdline: prettyCmdline, holderType: "chromium" };
130
+ }
131
+ function classifyXtigervnc(argv, prettyCmdline, pid, ownBrand, peerBrands) {
132
+ const display = parseDisplayArg(argv);
133
+ if (display === null) {
134
+ return { kind: "UNRELATED", pid, cmdline: prettyCmdline, holderType: "xtigervnc" };
135
+ }
136
+ if (display === ownBrand.vncDisplay) {
137
+ return {
138
+ kind: "OWN_BRAND", pid, cmdline: prettyCmdline,
139
+ vncDisplay: display, holderType: "xtigervnc",
140
+ };
141
+ }
142
+ for (const peer of peerBrands) {
143
+ if (display === peer.vncDisplay) {
144
+ return {
145
+ kind: "PEER_BRAND", pid, cmdline: prettyCmdline,
146
+ vncDisplay: display, holderType: "xtigervnc",
147
+ };
148
+ }
149
+ }
150
+ return {
151
+ kind: "UNRELATED", pid, cmdline: prettyCmdline,
152
+ vncDisplay: display, holderType: "xtigervnc",
153
+ };
154
+ }
155
+ function classifyWebsockify(argv, prettyCmdline, pid, ownBrand, peerBrands) {
156
+ const ports = collectPorts(argv);
157
+ if (ports.has(ownBrand.websockifyPort)) {
158
+ return {
159
+ kind: "OWN_BRAND", pid, cmdline: prettyCmdline,
160
+ websockifyPort: ownBrand.websockifyPort, holderType: "websockify",
161
+ };
162
+ }
163
+ for (const peer of peerBrands) {
164
+ if (ports.has(peer.websockifyPort)) {
165
+ return {
166
+ kind: "PEER_BRAND", pid, cmdline: prettyCmdline,
167
+ websockifyPort: peer.websockifyPort, holderType: "websockify",
168
+ };
169
+ }
170
+ }
171
+ return { kind: "UNRELATED", pid, cmdline: prettyCmdline, holderType: "websockify" };
172
+ }
173
+ // ---------------------------------------------------------------------------
174
+ // argv parsing helpers
175
+ // ---------------------------------------------------------------------------
176
+ // Find --flag=VALUE (single argv) or --flag VALUE (split across two argvs).
177
+ function findFlagValue(argv, flag) {
178
+ const PREFIX = `${flag}=`;
179
+ for (let i = 0; i < argv.length; i++) {
180
+ const a = argv[i];
181
+ if (a.startsWith(PREFIX))
182
+ return a.slice(PREFIX.length);
183
+ if (a === flag && i + 1 < argv.length)
184
+ return argv[i + 1];
185
+ }
186
+ return null;
187
+ }
188
+ // First non-flag argv of the form `:N` after argv[0]. vnc.sh's invocation
189
+ // puts the display in argv[1], but the function tolerates additional
190
+ // pre-positional flags by scanning past any `-`-prefixed token.
191
+ function parseDisplayArg(argv) {
192
+ for (let i = 1; i < argv.length; i++) {
193
+ const a = argv[i];
194
+ if (a.startsWith("-"))
195
+ continue;
196
+ const m = a.match(/^:(\d+)$/);
197
+ if (m)
198
+ return Number(m[1]);
199
+ }
200
+ return null;
201
+ }
202
+ // Collect every port number embedded in argv. Matches:
203
+ // "[::]:6080" → 6080 (websockify bind, IPv6 wildcard)
204
+ // "0.0.0.0:8080" → 8080 (bind, IPv4 wildcard)
205
+ // "localhost:5900" → 5900 (websockify upstream)
206
+ // "6080" → 6080 (bare positional)
207
+ // Skips flag tokens beginning with "-".
208
+ function collectPorts(argv) {
209
+ const ports = new Set();
210
+ const portRe = /(?::|^)(\d{1,5})$/;
211
+ for (const a of argv) {
212
+ if (a.startsWith("-"))
213
+ continue;
214
+ const m = a.match(portRe);
215
+ if (m === null)
216
+ continue;
217
+ const n = Number(m[1]);
218
+ if (n >= 1 && n <= 65535)
219
+ ports.add(n);
220
+ }
221
+ return ports;
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.850",
3
+ "version": "1.0.853",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -0,0 +1,44 @@
1
+ {
2
+ "brands": [
3
+ {
4
+ "hostname": "maxy",
5
+ "configDir": ".maxy",
6
+ "vncDisplay": 99,
7
+ "rfbPort": 5900,
8
+ "websockifyPort": 6080,
9
+ "cdpPort": 9222
10
+ },
11
+ {
12
+ "hostname": "maxy-2",
13
+ "configDir": ".maxy-2",
14
+ "vncDisplay": 101,
15
+ "rfbPort": 5902,
16
+ "websockifyPort": 6082,
17
+ "cdpPort": 9224
18
+ },
19
+ {
20
+ "hostname": "maxy-3",
21
+ "configDir": ".maxy-3",
22
+ "vncDisplay": 102,
23
+ "rfbPort": 5903,
24
+ "websockifyPort": 6083,
25
+ "cdpPort": 9225
26
+ },
27
+ {
28
+ "hostname": "maxy-4",
29
+ "configDir": ".maxy-4",
30
+ "vncDisplay": 103,
31
+ "rfbPort": 5904,
32
+ "websockifyPort": 6084,
33
+ "cdpPort": 9226
34
+ },
35
+ {
36
+ "hostname": "realagent",
37
+ "configDir": ".realagent",
38
+ "vncDisplay": 100,
39
+ "rfbPort": 5901,
40
+ "websockifyPort": 6081,
41
+ "cdpPort": 9223
42
+ }
43
+ ]
44
+ }
@@ -79,7 +79,7 @@ The executor can access these capabilities through agentic steps:
79
79
 
80
80
  | Capability | MCP Server | Infrastructure |
81
81
  |---|---|---|
82
- | Browser automation | `@playwright/mcp` | Chromium with CDP on port 9222 (started by `vnc.sh`, always running) |
82
+ | Browser automation | `@playwright/mcp` | Chromium with CDP on the brand's `cdpPort` from `brand.json` (started by `vnc.sh`, always running) |
83
83
  | Any MCP-compatible tool | Declared in step's `mcpServers` | Command must be in PATH on the device |
84
84
 
85
85
  The executor cannot:
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env bash
2
+ # Task 939 — post-publish device-side verification harness.
3
+ #
4
+ # Closes the verification gap that let Task 938 archive on source-diff alone:
5
+ # without a device run, neither (a) the classifier-extension fix nor (b) the
6
+ # CDP probe brand-aware fix shipped to actual hardware. This script SSHes to
7
+ # every device in a manifest, runs the published installer for that brand,
8
+ # then greps the install log for the canonical CDP success banner.
9
+ #
10
+ # Exit zero ↔ every device's installer reached "Browser automation ready
11
+ # (CDP connected)". Any other state — ssh failure, npx non-zero, missing
12
+ # banner — is a non-zero exit with the failing device named on stderr.
13
+ #
14
+ # CONTRACT
15
+ # Archival of any task that touches packages/create-maxy/** is contingent
16
+ # on this script exiting zero against the published version. The exit code
17
+ # and the per-device summary are quoted in the close commit body.
18
+ #
19
+ # USAGE
20
+ # $ installer-device-verify.sh <published-version>
21
+ #
22
+ # <published-version> e.g. 1.0.853 — appended to `npx -y @rubytech/create-<brand>@<version>`.
23
+ #
24
+ # MANIFEST
25
+ # Path: $MAXY_VERIFY_MANIFEST (default $HOME/.maxy-verify-devices.json).
26
+ # Format: JSON array of devices; each entry:
27
+ # {
28
+ # "name": "maxy-pi-test", // free-form display name
29
+ # "brand": "maxy", // npm package suffix (@rubytech/create-<brand>)
30
+ # "configDir": ".maxy", // brand configDir; logs live in $HOME/<configDir>/logs/
31
+ # "sshTarget": "admin@maxytest.local", // user@host for ssh
32
+ # "sshPass": "password" // optional; uses sshpass if present
33
+ # }
34
+ # sshTarget supports inline port via "user@host:port" — script splits if present.
35
+ # sshPass omission → relies on existing ssh keys / agent.
36
+ #
37
+ # OUTPUT
38
+ # Per-device summary line on stdout.
39
+ # Detailed log: $HOME/.maxy/logs/installer-verify-<runId>.log (created if absent).
40
+ #
41
+ # DEPENDENCIES
42
+ # ssh (always), sshpass (only if any device uses sshPass), jq (always — the
43
+ # manifest is JSON; bash-only parse would be a CVE waiting to happen).
44
+
45
+ set -euo pipefail
46
+
47
+ if [[ $# -ne 1 ]]; then
48
+ echo "ERROR: missing argument <published-version>" >&2
49
+ echo "Usage: $0 <published-version>" >&2
50
+ echo "Example: $0 1.0.853" >&2
51
+ exit 2
52
+ fi
53
+
54
+ VERSION="$1"
55
+ # Pattern-validate version before any remote-shell interpolation. The version
56
+ # flows into `npx -y @rubytech/create-<brand>@$VERSION` over SSH; a stray
57
+ # semicolon or backtick would land inside the remote shell. Operator-owned
58
+ # input but the principle is validate-at-boundary.
59
+ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
60
+ echo "ERROR: invalid version '$VERSION' — expected semver (e.g. 1.0.853 or 1.0.853-rc.1)" >&2
61
+ exit 2
62
+ fi
63
+
64
+ MANIFEST="${MAXY_VERIFY_MANIFEST:-$HOME/.maxy-verify-devices.json}"
65
+ RUN_ID="$(date +%Y%m%dT%H%M%SZ)-$$"
66
+ SUMMARY_DIR="$HOME/.maxy/logs"
67
+ SUMMARY_LOG="$SUMMARY_DIR/installer-verify-$RUN_ID.log"
68
+
69
+ if [[ ! -f "$MANIFEST" ]]; then
70
+ cat >&2 <<EOF
71
+ ERROR: device manifest not found at $MANIFEST.
72
+
73
+ Create one with this shape (JSON array, one entry per device):
74
+ [
75
+ {
76
+ "name": "maxy-pi-test",
77
+ "brand": "maxy",
78
+ "configDir": ".maxy",
79
+ "sshTarget": "admin@maxytest.local",
80
+ "sshPass": "password"
81
+ }
82
+ ]
83
+
84
+ Or set \$MAXY_VERIFY_MANIFEST to point elsewhere.
85
+ Device list reference: ~/.claude/projects/-Users-neo-getmaxy/memory/reference_device_ssh.md
86
+ EOF
87
+ exit 2
88
+ fi
89
+
90
+ if ! command -v jq >/dev/null 2>&1; then
91
+ echo "ERROR: jq is required (manifest is JSON)." >&2
92
+ echo " macOS: brew install jq" >&2
93
+ echo " Ubuntu: sudo apt-get install -y jq" >&2
94
+ exit 2
95
+ fi
96
+
97
+ mkdir -p "$SUMMARY_DIR"
98
+ : > "$SUMMARY_LOG"
99
+
100
+ # Resolve sshpass once: required only if any manifest entry has sshPass set.
101
+ NEEDS_SSHPASS=$(jq -r 'any(.[]; .sshPass != null and .sshPass != "")' "$MANIFEST")
102
+ if [[ "$NEEDS_SSHPASS" == "true" ]] && ! command -v sshpass >/dev/null 2>&1; then
103
+ echo "ERROR: manifest references sshPass but sshpass is not installed." >&2
104
+ echo " macOS: brew install hudochenkov/sshpass/sshpass" >&2
105
+ echo " Ubuntu: sudo apt-get install -y sshpass" >&2
106
+ exit 2
107
+ fi
108
+
109
+ DEVICE_COUNT=$(jq 'length' "$MANIFEST")
110
+ FAILED=0
111
+
112
+ # Common ssh hardening: short timeouts, no host-key prompt that would block
113
+ # when adding a new Pi to the fleet, and StrictHostKeyChecking=accept-new so
114
+ # fingerprints are recorded the first time but rejected on mismatch after.
115
+ SSH_OPTS=(
116
+ -o "ConnectTimeout=10"
117
+ -o "ServerAliveInterval=15"
118
+ -o "ServerAliveCountMax=2"
119
+ -o "StrictHostKeyChecking=accept-new"
120
+ -o "BatchMode=no"
121
+ )
122
+
123
+ run_ssh() {
124
+ local target="$1" pass="$2"
125
+ shift 2
126
+ if [[ -n "$pass" ]]; then
127
+ SSHPASS="$pass" sshpass -e ssh "${SSH_OPTS[@]}" "$target" "$@"
128
+ else
129
+ ssh "${SSH_OPTS[@]}" "$target" "$@"
130
+ fi
131
+ }
132
+
133
+ # Allowed brand pattern matches what bundle.js validates: lowercase
134
+ # alphanumeric, hyphens. Reject anything else before interpolating into
135
+ # remote shell commands. configDir mirrors the brand convention (a leading
136
+ # dot then the same character class), so the same shape passes through.
137
+ # `$NAME` is interpolated into `--hostname "$NAME"` over SSH — same boundary
138
+ # discipline applies; allow dots so it can carry mDNS hostnames like
139
+ # `maxytest.local`, but never quote characters or shell metacharacters.
140
+ ALLOWED='^[a-z0-9][a-z0-9-]*$'
141
+ ALLOWED_HOSTNAME='^[a-zA-Z0-9][a-zA-Z0-9.-]*$'
142
+
143
+ echo "installer-device-verify run=$RUN_ID version=$VERSION devices=$DEVICE_COUNT" | tee -a "$SUMMARY_LOG"
144
+
145
+ for i in $(seq 0 $((DEVICE_COUNT - 1))); do
146
+ NAME=$(jq -r ".[$i].name" "$MANIFEST")
147
+ BRAND=$(jq -r ".[$i].brand" "$MANIFEST")
148
+ CONFIGDIR=$(jq -r ".[$i].configDir" "$MANIFEST")
149
+ TARGET=$(jq -r ".[$i].sshTarget" "$MANIFEST")
150
+ PASS=$(jq -r ".[$i].sshPass // \"\"" "$MANIFEST")
151
+
152
+ if [[ ! "$BRAND" =~ $ALLOWED ]]; then
153
+ echo "FAIL $NAME — invalid brand '$BRAND' (must match $ALLOWED)" | tee -a "$SUMMARY_LOG"
154
+ FAILED=1
155
+ continue
156
+ fi
157
+
158
+ if [[ ! "$NAME" =~ $ALLOWED_HOSTNAME ]]; then
159
+ echo "FAIL device name '$NAME' rejected — must match $ALLOWED_HOSTNAME" | tee -a "$SUMMARY_LOG"
160
+ FAILED=1
161
+ continue
162
+ fi
163
+
164
+ # configDir always begins with '.' (e.g. .maxy); strip the dot and validate
165
+ # the remainder with the same allowed-character class.
166
+ CD_TAIL="${CONFIGDIR#.}"
167
+ if [[ "$CD_TAIL" == "$CONFIGDIR" || ! "$CD_TAIL" =~ $ALLOWED ]]; then
168
+ echo "FAIL $NAME — invalid configDir '$CONFIGDIR' (must be a leading dot + $ALLOWED)" | tee -a "$SUMMARY_LOG"
169
+ FAILED=1
170
+ continue
171
+ fi
172
+
173
+ echo "" | tee -a "$SUMMARY_LOG"
174
+ echo "[$NAME] brand=$BRAND target=$TARGET version=$VERSION" | tee -a "$SUMMARY_LOG"
175
+
176
+ # Step 1 — install. Run npx with auto-yes; redirect to the device's own
177
+ # install log path so the summary log on the operator side stays clean,
178
+ # and so the device-side log matches what reference_device_ssh.md would
179
+ # tail.
180
+ set +e
181
+ run_ssh "$TARGET" "$PASS" "npx -y @rubytech/create-$BRAND@$VERSION --hostname \"$NAME\"" >>"$SUMMARY_LOG" 2>&1
182
+ INSTALL_RC=$?
183
+ set -e
184
+
185
+ if [[ $INSTALL_RC -ne 0 ]]; then
186
+ echo "FAIL $NAME — install exited $INSTALL_RC (see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
187
+ FAILED=1
188
+ continue
189
+ fi
190
+
191
+ # Step 2 — confirm CDP banner in the latest install log on the device.
192
+ # logs are at $HOME/<configDir>/logs/install-*.log; pick the newest by
193
+ # mtime (`ls -t`) and grep for the canonical success banner emitted by
194
+ # the installer at packages/create-maxy/src/index.ts.
195
+ REMOTE_CHECK=$(cat <<'REMOTE'
196
+ set -euo pipefail
197
+ LOG_DIR="$HOME/__CONFIGDIR__/logs"
198
+ LATEST=$(ls -t "$LOG_DIR"/install-*.log 2>/dev/null | head -n1 || true)
199
+ if [[ -z "${LATEST:-}" ]]; then
200
+ echo "no-install-log"
201
+ exit 1
202
+ fi
203
+ echo "log=$LATEST"
204
+ if grep -F -q "Browser automation ready (CDP connected)" "$LATEST"; then
205
+ exit 0
206
+ fi
207
+ echo "banner-not-found"
208
+ tail -n 30 "$LATEST"
209
+ exit 1
210
+ REMOTE
211
+ )
212
+ REMOTE_CHECK="${REMOTE_CHECK//__CONFIGDIR__/$CONFIGDIR}"
213
+
214
+ set +e
215
+ run_ssh "$TARGET" "$PASS" "$REMOTE_CHECK" >>"$SUMMARY_LOG" 2>&1
216
+ CHECK_RC=$?
217
+ set -e
218
+
219
+ if [[ $CHECK_RC -eq 0 ]]; then
220
+ echo "OK $NAME — CDP banner present" | tee -a "$SUMMARY_LOG"
221
+ else
222
+ echo "FAIL $NAME — CDP banner missing (rc=$CHECK_RC, see $SUMMARY_LOG)" | tee -a "$SUMMARY_LOG"
223
+ FAILED=1
224
+ fi
225
+ done
226
+
227
+ echo "" | tee -a "$SUMMARY_LOG"
228
+ if [[ $FAILED -eq 0 ]]; then
229
+ echo "PASS — all $DEVICE_COUNT devices installed and reported CDP banner" | tee -a "$SUMMARY_LOG"
230
+ echo "summary: $SUMMARY_LOG"
231
+ exit 0
232
+ else
233
+ echo "FAIL — at least one device did not reach CDP banner; details in $SUMMARY_LOG" | tee -a "$SUMMARY_LOG"
234
+ echo "summary: $SUMMARY_LOG"
235
+ exit 1
236
+ fi