@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.
- package/dist/__tests__/preflight-port-classifier.test.js +330 -0
- package/dist/index.js +179 -28
- package/dist/preflight-port-classifier.js +222 -0
- package/package.json +1 -1
- package/payload/platform/config/brand-registry.json +44 -0
- package/payload/platform/plugins/workflows/PLUGIN.md +1 -1
- package/payload/platform/scripts/installer-device-verify.sh +236 -0
|
@@ -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
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2461
|
-
//
|
|
2462
|
-
//
|
|
2463
|
-
|
|
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
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
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
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
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(
|
|
2635
|
-
const cdpCheck = spawnSync("curl", ["-sf",
|
|
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(
|
|
2653
|
-
console.error(
|
|
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
|
@@ -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
|
|
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
|