@rubytech/create-realagent 1.0.852 → 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.
@@ -1,163 +1,330 @@
1
- // Task 938 — acceptance grid for classifyPortHolder.
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.
2
4
  //
3
5
  // The wrapper in index.ts owns ss(8), /proc reads, kill(2), and the operator-
4
6
  // override exit. This suite exercises only the pure decision rule: ssOutput +
5
- // configDir context → kind. Inputs in, decision out — no fs, no exec, no spawn.
7
+ // brand identities → kind. Inputs in, decision out — no fs, no exec, no spawn.
6
8
  //
7
9
  // Cmdlines below mimic /proc/<pid>/cmdline format: argv joined by NUL bytes,
8
- // possibly with a trailing NUL. The classifier requires this format so it can
9
- // distinguish `--user-data-dir=PATH` (a real ownership claim) from a different
10
- // flag whose value happens to contain the profile path substring.
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.
11
13
  //
12
14
  // Runs via Node's built-in test runner — same convention as
13
15
  // peer-brand-detect.test.ts.
14
16
  import test from "node:test";
15
17
  import assert from "node:assert/strict";
16
18
  import { classifyPortHolder } from "../preflight-port-classifier.js";
17
- const ALL_BRANDS = [".maxy", ".realagent", ".maxy-2", ".maxy-3", ".maxy-4"];
18
- const peers = (own) => ALL_BRANDS.filter(c => c !== own);
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);
19
32
  const SS_PID_42 = "LISTEN 0 4096 *:9222 *:* users:((\"chrome\",pid=42,fd=12))";
20
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))";
21
37
  const cmd = (...argv) => argv.join("\0") + "\0";
38
+ // ---------------------------------------------------------------------------
39
+ // EMPTY / no pid / cmdline race
40
+ // ---------------------------------------------------------------------------
22
41
  test("empty ssOutput → EMPTY", () => {
23
42
  const r = classifyPortHolder({
24
43
  ssOutput: "",
25
- ownConfigDir: ".maxy",
26
- peerConfigDirs: peers(".maxy"),
44
+ ownBrand: MAXY,
45
+ peerBrands: peers(MAXY),
27
46
  getCmdline: () => { throw new Error("should not be called"); },
28
47
  });
29
48
  assert.equal(r.kind, "EMPTY");
30
49
  });
31
- test("OWN_BRAND: --user-data-dir=PATH single argv", () => {
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", () => {
32
90
  const cmdline = cmd("/opt/google/chrome/chrome", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
33
91
  const r = classifyPortHolder({
34
92
  ssOutput: SS_PID_42,
35
- ownConfigDir: ".maxy",
36
- peerConfigDirs: peers(".maxy"),
93
+ ownBrand: MAXY,
94
+ peerBrands: peers(MAXY),
37
95
  getCmdline: pid => { assert.equal(pid, 42); return cmdline; },
38
96
  });
39
97
  assert.equal(r.kind, "OWN_BRAND");
98
+ assert.equal(r.holderType, "chromium");
40
99
  assert.equal(r.pid, 42);
41
100
  assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
42
101
  });
43
- test("OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
102
+ test("chromium OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
44
103
  const cmdline = cmd("/usr/bin/chromium", "--user-data-dir", "/home/admin/.maxy/chromium-profile", "--remote-debugging-port=9222");
45
104
  const r = classifyPortHolder({
46
105
  ssOutput: SS_PID_42,
47
- ownConfigDir: ".maxy",
48
- peerConfigDirs: peers(".maxy"),
106
+ ownBrand: MAXY,
107
+ peerBrands: peers(MAXY),
49
108
  getCmdline: () => cmdline,
50
109
  });
51
110
  assert.equal(r.kind, "OWN_BRAND");
111
+ assert.equal(r.holderType, "chromium");
52
112
  assert.equal(r.profilePath, "/home/admin/.maxy/chromium-profile");
53
113
  });
54
- test("PEER_BRAND: cmdline holds another known brand's profile path", () => {
114
+ test("chromium PEER_BRAND: cmdline holds another known brand's profile path", () => {
55
115
  const cmdline = cmd("/usr/bin/chromium", "--user-data-dir=/home/admin/.realagent/chromium-profile", "--remote-debugging-port=9223");
56
116
  const r = classifyPortHolder({
57
117
  ssOutput: SS_PID_99,
58
- ownConfigDir: ".maxy",
59
- peerConfigDirs: peers(".maxy"),
118
+ ownBrand: MAXY,
119
+ peerBrands: peers(MAXY),
60
120
  getCmdline: () => cmdline,
61
121
  });
62
122
  assert.equal(r.kind, "PEER_BRAND");
123
+ assert.equal(r.holderType, "chromium");
63
124
  assert.equal(r.pid, 99);
64
125
  assert.equal(r.profilePath, "/home/admin/.realagent/chromium-profile");
65
126
  });
66
- test("UNRELATED: cmdline matches no known brand profile", () => {
67
- const cmdline = cmd("/usr/bin/python3", "-m", "http.server", "9222");
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");
68
131
  const r = classifyPortHolder({
69
132
  ssOutput: SS_PID_42,
70
- ownConfigDir: ".maxy",
71
- peerConfigDirs: peers(".maxy"),
133
+ ownBrand: MAXY,
134
+ peerBrands: peers(MAXY),
72
135
  getCmdline: () => cmdline,
73
136
  });
74
137
  assert.equal(r.kind, "UNRELATED");
138
+ assert.equal(r.holderType, "chromium");
75
139
  assert.equal(r.pid, 42);
76
- assert.equal(r.cmdline, "/usr/bin/python3 -m http.server 9222");
77
- });
78
- test("UNRELATED: ssOutput non-empty but no pid= token", () => {
79
- const r = classifyPortHolder({
80
- ssOutput: "Recv-Q Send-Q Local Address:Port Peer Address:Port",
81
- ownConfigDir: ".maxy",
82
- peerConfigDirs: peers(".maxy"),
83
- getCmdline: () => { throw new Error("should not be called"); },
84
- });
85
- assert.equal(r.kind, "UNRELATED");
86
- assert.equal(r.pid, undefined);
87
- });
88
- test("UNRELATED: getCmdline throws (race — process exited between ss and read)", () => {
89
- const r = classifyPortHolder({
90
- ssOutput: SS_PID_42,
91
- ownConfigDir: ".maxy",
92
- peerConfigDirs: peers(".maxy"),
93
- getCmdline: () => { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; },
94
- });
95
- assert.equal(r.kind, "UNRELATED");
96
- assert.equal(r.pid, 42);
97
- assert.equal(r.cmdlineReadFailed, true);
98
140
  });
99
- test("UNRELATED: --user-data-dir absent (no profile claim)", () => {
141
+ test("chromium UNRELATED: --user-data-dir absent (no profile claim)", () => {
100
142
  // Kernel threads, GPU/zygote/utility chrome processes inherit profile via
101
- // fork without re-stating --user-data-dir on their argv. They wouldn't be
102
- // listening anyway, but if one ever showed up here we must classify as
103
- // UNRELATED, never as OWN_BRAND on a substring fluke.
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.
104
145
  const cmdline = cmd("/opt/google/chrome/chrome", "--type=gpu-process", "--no-sandbox");
105
146
  const r = classifyPortHolder({
106
147
  ssOutput: SS_PID_42,
107
- ownConfigDir: ".maxy",
108
- peerConfigDirs: peers(".maxy"),
148
+ ownBrand: MAXY,
149
+ peerBrands: peers(MAXY),
109
150
  getCmdline: () => cmdline,
110
151
  });
111
152
  assert.equal(r.kind, "UNRELATED");
153
+ assert.equal(r.holderType, "chromium");
112
154
  });
113
- test("substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
155
+ test("chromium substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
114
156
  const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy-2/chromium-profile");
115
157
  const r = classifyPortHolder({
116
158
  ssOutput: SS_PID_42,
117
- ownConfigDir: ".maxy",
118
- peerConfigDirs: peers(".maxy"),
159
+ ownBrand: MAXY,
160
+ peerBrands: peers(MAXY),
119
161
  getCmdline: () => cmdline,
120
162
  });
121
163
  assert.equal(r.kind, "PEER_BRAND");
122
164
  assert.equal(r.profilePath, "/home/neo/.maxy-2/chromium-profile");
123
165
  });
124
- test("substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
166
+ test("chromium substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
125
167
  const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
126
168
  const r = classifyPortHolder({
127
169
  ssOutput: SS_PID_42,
128
- ownConfigDir: ".maxy-2",
129
- peerConfigDirs: peers(".maxy-2"),
170
+ ownBrand: MAXY_2,
171
+ peerBrands: peers(MAXY_2),
130
172
  getCmdline: () => cmdline,
131
173
  });
132
174
  assert.equal(r.kind, "PEER_BRAND");
133
175
  assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
134
176
  });
135
- test("argv-anchor safety: marker in --load-extension value must NOT classify as OWN_BRAND", () => {
136
- // Adversarial case from review: a Chrome flag whose value contains the
137
- // profile-path substring, but no actual --user-data-dir claim. Naive
138
- // substring matching would false-positive OWN_BRAND.
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.
139
181
  const cmdline = cmd("chromium", "--load-extension=/tmp/decoy/.maxy/chromium-profile", "--remote-debugging-port=9222");
140
182
  const r = classifyPortHolder({
141
183
  ssOutput: SS_PID_42,
142
- ownConfigDir: ".maxy",
143
- peerConfigDirs: peers(".maxy"),
184
+ ownBrand: MAXY,
185
+ peerBrands: peers(MAXY),
144
186
  getCmdline: () => cmdline,
145
187
  });
146
188
  assert.equal(r.kind, "UNRELATED");
147
189
  });
148
- test("ss header line containing 'pid=': last pid= wins", () => {
149
- // If a future ss locale prepends header text containing the literal `pid=`
150
- // (e.g., "Process pid="), the first-match heuristic would lift the wrong
151
- // value. The LISTEN row is always last, so taking the last pid= match is
152
- // the structural fix.
153
- const ssOutput = `State pid= notes\nLISTEN 0 4096 *:9222 *:* users:(("chrome",pid=42,fd=12))`;
154
- const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
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");
155
195
  const r = classifyPortHolder({
156
- ssOutput,
157
- ownConfigDir: ".maxy",
158
- peerConfigDirs: peers(".maxy"),
159
- getCmdline: pid => { assert.equal(pid, 42, "must pick the LISTEN row's pid, not the header's"); return cmdline; },
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,
160
286
  });
161
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");
162
328
  assert.equal(r.pid, 42);
329
+ assert.equal(r.cmdline, "/usr/bin/python3 -m http.server 9222");
163
330
  });
package/dist/index.js CHANGED
@@ -2457,12 +2457,13 @@ function installService() {
2457
2457
  // three brand-scoped ports is already held by a process that is NOT this
2458
2458
  // brand's own on-demand browser nor a peer brand's edge stack.
2459
2459
  //
2460
- // Classification (Task 938) reads `/proc/<pid>/cmdline` and matches on the
2461
- // user-data-dir profile path, not on `comm` regex. The previous
2462
- // `comm`-regex approach (`/Xtigervnc|websockify|chromium/`) failed on the
2463
- // laptop where Task 929 selects google-chrome-stable: comm is `chrome`,
2464
- // so the brand's own browser was misclassified UNRELATED and the install
2465
- // aborted against a port it could legitimately reclaim.
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.
2466
2467
  //
2467
2468
  // Decisions per holder:
2468
2469
  // OWN_BRAND — SIGTERM, recheck, SIGKILL on stragglers, exit-1 only if
@@ -2471,9 +2472,42 @@ function installService() {
2471
2472
  // UNRELATED — refuse to write service files; emit operator override.
2472
2473
  // macOS dev hosts (no ss) fall through the catch and skip pre-flight
2473
2474
  // entirely — the runtime check in vnc.sh covers Linux production.
2474
- const peerConfigDirs = KNOWN_BRAND_HOSTNAMES
2475
- .filter(h => h !== BRAND.hostname)
2476
- .map(h => `.${h}`);
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
+ }
2494
+ try {
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 });
2503
+ }
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
+ })();
2477
2511
  const ssReadHolder = (port) => {
2478
2512
  return execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
2479
2513
  encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
@@ -2513,8 +2547,22 @@ function installService() {
2513
2547
  }
2514
2548
  };
2515
2549
  const classify = (ssOutput) => classifyPortHolder({
2516
- ssOutput, ownConfigDir: BRAND.configDir, peerConfigDirs, getCmdline: readCmdline,
2550
+ ssOutput, ownBrand, peerBrands, getCmdline: readCmdline,
2517
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
+ };
2518
2566
  const checkInstallPortFree = (label, port) => {
2519
2567
  let firstSsOutput;
2520
2568
  try {
@@ -2541,7 +2589,7 @@ function installService() {
2541
2589
  return;
2542
2590
  }
2543
2591
  if (r.kind === "OWN_BRAND" && r.pid !== undefined) {
2544
- logFile(` [preflight] ${label}=${port} held by OWN brand process pid=${r.pid} profile=${r.profilePath} — sending SIGTERM`);
2592
+ logFile(ownBrandAnnounceLine(label, port, r));
2545
2593
  killNoThrow(r.pid, "SIGTERM");
2546
2594
  sleepMs(300);
2547
2595
  const after = classify(ssReadOrAbort(label, port));
@@ -1,32 +1,39 @@
1
1
  // Task 938 — pure classifier for the install-time port collision pre-flight.
2
- // Extracted from index.ts so the OWN_BRAND / PEER_BRAND / UNRELATED decision
3
- // can be unit-tested without ss(8), /proc, or kill(2). Mirrors the
4
- // peer-brand-detect.ts pattern: inputs in, classification out, no I/O.
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
5
  //
6
6
  // The wrapper in index.ts owns the side effects: ss read, /proc/<pid>/cmdline
7
7
  // read, SIGTERM/SIGKILL escalation, and the operator-override exit. This
8
- // module owns only the classification rule.
8
+ // module owns only the classification rule — inputs in, decision out.
9
9
  //
10
- // Rule, in order of precedence:
11
- // 1. ssOutput is empty EMPTY
12
- // 2. No `pid=N` token in ssOutputUNRELATED (pid undef)
13
- // 3. getCmdline(pid) throws UNRELATED (cmdlineReadFailed)
14
- // 4. argv has --user-data-dir=PATH where PATH contains
15
- // `/<ownConfigDir>/chromium-profile` OWN_BRAND
16
- // 5. same for any `<peerConfigDir>` → PEER_BRAND
17
- // 6. otherwise UNRELATED
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
18
  //
19
- // Why argv parsing instead of substring matching: `cmdline.includes('/.maxy/
20
- // chromium-profile')` would false-match `--load-extension=/tmp/.maxy/chromium-
21
- // profile` (or any other flag whose value happens to contain the profile path
22
- // component). Anchoring on `--user-data-dir=` is the only signal that this
23
- // process actually owns that profile directory.
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.
24
32
  //
25
33
  // Why "last pid=" instead of "first pid=": ss with `-tlnpH sport = :PORT`
26
- // emits the LISTEN row last; if a future ss locale or release prepends a
27
- // header line that contains `pid=` text, the first-match heuristic would
28
- // lift the wrong pid. The last `pid=` is always inside the LISTEN row's
29
- // `users:((…))` segment.
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:((…))`.
30
37
  /**
31
38
  * `cmdline` should be the raw `/proc/<pid>/cmdline` contents — NUL-separated
32
39
  * argv as the kernel emits it. Do NOT replace NUL with space before passing:
@@ -34,11 +41,9 @@
34
41
  * different flag whose value happens to contain `--user-data-dir=`.
35
42
  */
36
43
  export function classifyPortHolder(args) {
37
- const { ssOutput, ownConfigDir, peerConfigDirs, getCmdline } = args;
44
+ const { ssOutput, ownBrand, peerBrands, getCmdline } = args;
38
45
  if (ssOutput.trim() === "")
39
46
  return { kind: "EMPTY" };
40
- // Take the LAST pid= match. ss -tlnpH puts the LISTEN row (which contains
41
- // `users:((…,pid=N,fd=…))`) last, so the last pid= is always the listener.
42
47
  const pidMatches = [...ssOutput.matchAll(/pid=(\d+)/g)];
43
48
  if (pidMatches.length === 0)
44
49
  return { kind: "UNRELATED" };
@@ -50,38 +55,168 @@ export function classifyPortHolder(args) {
50
55
  catch {
51
56
  return { kind: "UNRELATED", pid, cmdlineReadFailed: true };
52
57
  }
53
- // Pretty cmdline (NULs → spaces) for log output. The argv-aware matching
54
- // operates on the raw NUL-separated form.
55
58
  const prettyCmdline = cmdline.replace(/\0/g, " ").trim();
56
59
  const argv = cmdline.split("\0").filter(s => s.length > 0);
57
- const userDataDir = findUserDataDir(argv);
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");
58
106
  if (userDataDir === null) {
59
- return { kind: "UNRELATED", pid, cmdline: prettyCmdline };
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" };
60
112
  }
61
- const ownSuffix = `/${ownConfigDir}/chromium-profile`;
113
+ const ownSuffix = `/${ownBrand.configDir}/chromium-profile`;
62
114
  if (userDataDir.includes(ownSuffix)) {
63
- return { kind: "OWN_BRAND", pid, cmdline: prettyCmdline, profilePath: userDataDir };
115
+ return {
116
+ kind: "OWN_BRAND", pid, cmdline: prettyCmdline,
117
+ profilePath: userDataDir, holderType: "chromium",
118
+ };
64
119
  }
65
- for (const peerCD of peerConfigDirs) {
66
- const peerSuffix = `/${peerCD}/chromium-profile`;
120
+ for (const peer of peerBrands) {
121
+ const peerSuffix = `/${peer.configDir}/chromium-profile`;
67
122
  if (userDataDir.includes(peerSuffix)) {
68
- return { kind: "PEER_BRAND", pid, cmdline: prettyCmdline, profilePath: userDataDir };
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
+ };
69
148
  }
70
149
  }
71
- return { kind: "UNRELATED", pid, cmdline: prettyCmdline };
150
+ return {
151
+ kind: "UNRELATED", pid, cmdline: prettyCmdline,
152
+ vncDisplay: display, holderType: "xtigervnc",
153
+ };
72
154
  }
73
- // Find the value of `--user-data-dir`, supporting both `--user-data-dir=PATH`
74
- // (single argv) and `--user-data-dir PATH` (split across two argvs). Returns
75
- // null if the flag is absent.
76
- function findUserDataDir(argv) {
77
- const FLAG = "--user-data-dir";
78
- const PREFIX = `${FLAG}=`;
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}=`;
79
179
  for (let i = 0; i < argv.length; i++) {
80
180
  const a = argv[i];
81
181
  if (a.startsWith(PREFIX))
82
182
  return a.slice(PREFIX.length);
83
- if (a === FLAG && i + 1 < argv.length)
183
+ if (a === flag && i + 1 < argv.length)
84
184
  return argv[i + 1];
85
185
  }
86
186
  return null;
87
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.852",
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
+ }
@@ -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