@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.
- package/dist/__tests__/preflight-port-classifier.test.js +240 -73
- package/dist/index.js +59 -11
- package/dist/preflight-port-classifier.js +176 -41
- package/package.json +1 -1
- package/payload/platform/config/brand-registry.json +44 -0
- package/payload/platform/scripts/installer-device-verify.sh +236 -0
|
@@ -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
|
-
//
|
|
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
|
|
9
|
-
// distinguish
|
|
10
|
-
// flag whose value happens to contain the
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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("
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
|
102
|
-
//
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
136
|
-
// Adversarial case
|
|
137
|
-
//
|
|
138
|
-
//
|
|
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
|
-
|
|
143
|
-
|
|
184
|
+
ownBrand: MAXY,
|
|
185
|
+
peerBrands: peers(MAXY),
|
|
144
186
|
getCmdline: () => cmdline,
|
|
145
187
|
});
|
|
146
188
|
assert.equal(r.kind, "UNRELATED");
|
|
147
189
|
});
|
|
148
|
-
test("
|
|
149
|
-
//
|
|
150
|
-
// (
|
|
151
|
-
//
|
|
152
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
getCmdline:
|
|
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
|
|
2461
|
-
//
|
|
2462
|
-
//
|
|
2463
|
-
//
|
|
2464
|
-
//
|
|
2465
|
-
//
|
|
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
|
|
2475
|
-
|
|
2476
|
-
|
|
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,
|
|
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(
|
|
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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 = `/${
|
|
113
|
+
const ownSuffix = `/${ownBrand.configDir}/chromium-profile`;
|
|
62
114
|
if (userDataDir.includes(ownSuffix)) {
|
|
63
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
kind: "OWN_BRAND", pid, cmdline: prettyCmdline,
|
|
117
|
+
profilePath: userDataDir, holderType: "chromium",
|
|
118
|
+
};
|
|
64
119
|
}
|
|
65
|
-
for (const
|
|
66
|
-
const peerSuffix = `/${
|
|
120
|
+
for (const peer of peerBrands) {
|
|
121
|
+
const peerSuffix = `/${peer.configDir}/chromium-profile`;
|
|
67
122
|
if (userDataDir.includes(peerSuffix)) {
|
|
68
|
-
return {
|
|
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 {
|
|
150
|
+
return {
|
|
151
|
+
kind: "UNRELATED", pid, cmdline: prettyCmdline,
|
|
152
|
+
vncDisplay: display, holderType: "xtigervnc",
|
|
153
|
+
};
|
|
72
154
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 ===
|
|
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
|
@@ -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
|