@rubytech/create-realagent 1.0.852 → 1.0.854

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/__tests__/preflight-port-classifier.test.js +240 -73
  2. package/dist/index.js +59 -11
  3. package/dist/preflight-port-classifier.js +176 -41
  4. package/package.json +1 -1
  5. package/payload/platform/config/brand-registry.json +44 -0
  6. package/payload/platform/lib/persistent-components/dist/index.d.ts +21 -0
  7. package/payload/platform/lib/persistent-components/dist/index.d.ts.map +1 -0
  8. package/payload/platform/lib/persistent-components/dist/index.js +32 -0
  9. package/payload/platform/lib/persistent-components/dist/index.js.map +1 -0
  10. package/payload/platform/lib/persistent-components/src/index.ts +28 -0
  11. package/payload/platform/lib/persistent-components/tsconfig.json +8 -0
  12. package/payload/platform/package.json +2 -2
  13. package/payload/platform/plugins/admin/PLUGIN.md +1 -1
  14. package/payload/platform/plugins/admin/hooks/__tests__/playwright-file-guard.test.sh +278 -0
  15. package/payload/platform/plugins/admin/hooks/playwright-file-guard.sh +204 -20
  16. package/payload/platform/plugins/admin/mcp/dist/index.js +40 -1
  17. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/docs/references/deployment.md +2 -0
  19. package/payload/platform/plugins/docs/references/getting-started.md +2 -0
  20. package/payload/platform/plugins/docs/references/platform.md +1 -1
  21. package/payload/platform/plugins/docs/references/troubleshooting.md +10 -0
  22. package/payload/platform/scripts/admin-persist-audit.ts +191 -0
  23. package/payload/platform/scripts/component-knowledgedoc-backfill.ts +214 -0
  24. package/payload/platform/scripts/installer-device-verify.sh +249 -0
  25. package/payload/platform/templates/specialists/agents/content-producer.md +2 -2
  26. package/payload/server/chunk-CFNSKDGA.js +667 -0
  27. package/payload/server/chunk-DC6DWYZJ.js +1603 -0
  28. package/payload/server/chunk-LTB5SSQW.js +10889 -0
  29. package/payload/server/chunk-MN2LGNUB.js +2143 -0
  30. package/payload/server/client-pool-AMT2W3II.js +34 -0
  31. package/payload/server/cloudflare-task-tracker-LJ4SMK2D.js +20 -0
  32. package/payload/server/maxy-edge.js +3 -3
  33. package/payload/server/public/assets/admin-DZ8Ke7t3.js +352 -0
  34. package/payload/server/public/assets/public-DApUXgoq.js +5 -0
  35. package/payload/server/public/assets/useVoiceRecorder-CI8GpxfU.js +36 -0
  36. package/payload/server/public/index.html +2 -2
  37. package/payload/server/public/public.html +2 -2
  38. package/payload/server/server.js +535 -351
  39. package/payload/server/public/assets/admin-Dyl8uNxX.js +0 -352
  40. package/payload/server/public/assets/public-B_PNZUph.js +0 -5
  41. package/payload/server/public/assets/useVoiceRecorder-fD0IWzJj.js +0 -36
@@ -1,163 +1,330 @@
1
- // Task 938 — acceptance grid for classifyPortHolder.
1
+ // Task 938 — acceptance grid for classifyPortHolder (chromium argv-anchor).
2
+ // Task 939 — extended grid for Xtigervnc display anchor + websockify port
3
+ // anchor + holder-type detection on argv[0] basename / full-argv scan.
2
4
  //
3
5
  // The wrapper in index.ts owns ss(8), /proc reads, kill(2), and the operator-
4
6
  // override exit. This suite exercises only the pure decision rule: ssOutput +
5
- // configDir context → kind. Inputs in, decision out — no fs, no exec, no spawn.
7
+ // brand identities → kind. Inputs in, decision out — no fs, no exec, no spawn.
6
8
  //
7
9
  // Cmdlines below mimic /proc/<pid>/cmdline format: argv joined by NUL bytes,
8
- // possibly with a trailing NUL. The classifier requires this format so it can
9
- // distinguish `--user-data-dir=PATH` (a real ownership claim) from a different
10
- // flag whose value happens to contain the profile path substring.
10
+ // possibly with a trailing NUL. The classifier requires this format so it
11
+ // can distinguish a flag value (a real ownership claim) from a different
12
+ // flag whose value happens to contain the marker substring.
11
13
  //
12
14
  // Runs via Node's built-in test runner — same convention as
13
15
  // peer-brand-detect.test.ts.
14
16
  import test from "node:test";
15
17
  import assert from "node:assert/strict";
16
18
  import { classifyPortHolder } from "../preflight-port-classifier.js";
17
- const ALL_BRANDS = [".maxy", ".realagent", ".maxy-2", ".maxy-3", ".maxy-4"];
18
- const peers = (own) => ALL_BRANDS.filter(c => c !== own);
19
+ // Per-brand identities mirror the live brands/<brand>/brand.json values that
20
+ // bundle.js stamps into payload/platform/config/brand-registry.json. Values
21
+ // are derived from the brand manifests, not hardcoded as ground-truth here:
22
+ // the test suite asserts the rule, not the numbers. Any rebalance in
23
+ // brands/*/brand.json updates the registry on the next bundle and the
24
+ // classifier consumes the new values without source change.
25
+ const MAXY = { configDir: ".maxy", vncDisplay: 99, websockifyPort: 6080 };
26
+ const REAL = { configDir: ".realagent", vncDisplay: 100, websockifyPort: 6081 };
27
+ const MAXY_2 = { configDir: ".maxy-2", vncDisplay: 101, websockifyPort: 6082 };
28
+ const MAXY_3 = { configDir: ".maxy-3", vncDisplay: 102, websockifyPort: 6083 };
29
+ const MAXY_4 = { configDir: ".maxy-4", vncDisplay: 103, websockifyPort: 6084 };
30
+ const ALL = [MAXY, REAL, MAXY_2, MAXY_3, MAXY_4];
31
+ const peers = (own) => ALL.filter(b => b.configDir !== own.configDir);
19
32
  const SS_PID_42 = "LISTEN 0 4096 *:9222 *:* users:((\"chrome\",pid=42,fd=12))";
20
33
  const SS_PID_99 = "LISTEN 0 4096 *:9223 *:* users:((\"chrome\",pid=99,fd=12))";
34
+ const SS_PID_777 = "LISTEN 0 5 127.0.0.1:5900 0.0.0.0:* users:((\"Xtigervnc\",pid=777,fd=9))";
35
+ const SS_PID_888 = "LISTEN 0 5 127.0.0.1:5901 0.0.0.0:* users:((\"Xtigervnc\",pid=888,fd=9))";
36
+ const SS_PID_555 = "LISTEN 0 5 *:6080 *:* users:((\"websockify\",pid=555,fd=8))";
21
37
  const cmd = (...argv) => argv.join("\0") + "\0";
38
+ // ---------------------------------------------------------------------------
39
+ // EMPTY / no pid / cmdline race
40
+ // ---------------------------------------------------------------------------
22
41
  test("empty ssOutput → EMPTY", () => {
23
42
  const r = classifyPortHolder({
24
43
  ssOutput: "",
25
- ownConfigDir: ".maxy",
26
- peerConfigDirs: peers(".maxy"),
44
+ ownBrand: MAXY,
45
+ peerBrands: peers(MAXY),
27
46
  getCmdline: () => { throw new Error("should not be called"); },
28
47
  });
29
48
  assert.equal(r.kind, "EMPTY");
30
49
  });
31
- test("OWN_BRAND: --user-data-dir=PATH single argv", () => {
50
+ test("UNRELATED: ssOutput non-empty but no pid= token", () => {
51
+ const r = classifyPortHolder({
52
+ ssOutput: "Recv-Q Send-Q Local Address:Port Peer Address:Port",
53
+ ownBrand: MAXY,
54
+ peerBrands: peers(MAXY),
55
+ getCmdline: () => { throw new Error("should not be called"); },
56
+ });
57
+ assert.equal(r.kind, "UNRELATED");
58
+ assert.equal(r.pid, undefined);
59
+ });
60
+ test("UNRELATED: getCmdline throws (race — process exited between ss and read)", () => {
61
+ const r = classifyPortHolder({
62
+ ssOutput: SS_PID_42,
63
+ ownBrand: MAXY,
64
+ peerBrands: peers(MAXY),
65
+ getCmdline: () => { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; },
66
+ });
67
+ assert.equal(r.kind, "UNRELATED");
68
+ assert.equal(r.pid, 42);
69
+ assert.equal(r.cmdlineReadFailed, true);
70
+ });
71
+ test("ss header line containing 'pid=': last pid= wins", () => {
72
+ // If a future ss locale prepends header text containing the literal `pid=`,
73
+ // the first-match heuristic would lift the wrong value. The LISTEN row is
74
+ // always last, so taking the last pid= match is the structural fix.
75
+ const ssOutput = `State pid= notes\nLISTEN 0 4096 *:9222 *:* users:(("chrome",pid=42,fd=12))`;
76
+ const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
77
+ const r = classifyPortHolder({
78
+ ssOutput,
79
+ ownBrand: MAXY,
80
+ peerBrands: peers(MAXY),
81
+ getCmdline: pid => { assert.equal(pid, 42, "must pick the LISTEN row's pid"); return cmdline; },
82
+ });
83
+ assert.equal(r.kind, "OWN_BRAND");
84
+ assert.equal(r.pid, 42);
85
+ });
86
+ // ---------------------------------------------------------------------------
87
+ // Chromium (Task 938 grid, migrated to BrandIdentity API)
88
+ // ---------------------------------------------------------------------------
89
+ test("chromium OWN_BRAND: --user-data-dir=PATH single argv", () => {
32
90
  const cmdline = cmd("/opt/google/chrome/chrome", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
33
91
  const r = classifyPortHolder({
34
92
  ssOutput: SS_PID_42,
35
- ownConfigDir: ".maxy",
36
- peerConfigDirs: peers(".maxy"),
93
+ ownBrand: MAXY,
94
+ peerBrands: peers(MAXY),
37
95
  getCmdline: pid => { assert.equal(pid, 42); return cmdline; },
38
96
  });
39
97
  assert.equal(r.kind, "OWN_BRAND");
98
+ assert.equal(r.holderType, "chromium");
40
99
  assert.equal(r.pid, 42);
41
100
  assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
42
101
  });
43
- test("OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
102
+ test("chromium OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
44
103
  const cmdline = cmd("/usr/bin/chromium", "--user-data-dir", "/home/admin/.maxy/chromium-profile", "--remote-debugging-port=9222");
45
104
  const r = classifyPortHolder({
46
105
  ssOutput: SS_PID_42,
47
- ownConfigDir: ".maxy",
48
- peerConfigDirs: peers(".maxy"),
106
+ ownBrand: MAXY,
107
+ peerBrands: peers(MAXY),
49
108
  getCmdline: () => cmdline,
50
109
  });
51
110
  assert.equal(r.kind, "OWN_BRAND");
111
+ assert.equal(r.holderType, "chromium");
52
112
  assert.equal(r.profilePath, "/home/admin/.maxy/chromium-profile");
53
113
  });
54
- test("PEER_BRAND: cmdline holds another known brand's profile path", () => {
114
+ test("chromium PEER_BRAND: cmdline holds another known brand's profile path", () => {
55
115
  const cmdline = cmd("/usr/bin/chromium", "--user-data-dir=/home/admin/.realagent/chromium-profile", "--remote-debugging-port=9223");
56
116
  const r = classifyPortHolder({
57
117
  ssOutput: SS_PID_99,
58
- ownConfigDir: ".maxy",
59
- peerConfigDirs: peers(".maxy"),
118
+ ownBrand: MAXY,
119
+ peerBrands: peers(MAXY),
60
120
  getCmdline: () => cmdline,
61
121
  });
62
122
  assert.equal(r.kind, "PEER_BRAND");
123
+ assert.equal(r.holderType, "chromium");
63
124
  assert.equal(r.pid, 99);
64
125
  assert.equal(r.profilePath, "/home/admin/.realagent/chromium-profile");
65
126
  });
66
- test("UNRELATED: cmdline matches no known brand profile", () => {
67
- const cmdline = cmd("/usr/bin/python3", "-m", "http.server", "9222");
127
+ test("chromium UNRELATED: cmdline matches no known brand profile", () => {
128
+ // Anchors only on the basename — "/usr/bin/python3" is not chromium.
129
+ // Use an actual chromium binary with a non-brand profile path instead.
130
+ const cmdline = cmd("chromium", "--user-data-dir=/tmp/scratch", "--remote-debugging-port=9999");
68
131
  const r = classifyPortHolder({
69
132
  ssOutput: SS_PID_42,
70
- ownConfigDir: ".maxy",
71
- peerConfigDirs: peers(".maxy"),
133
+ ownBrand: MAXY,
134
+ peerBrands: peers(MAXY),
72
135
  getCmdline: () => cmdline,
73
136
  });
74
137
  assert.equal(r.kind, "UNRELATED");
138
+ assert.equal(r.holderType, "chromium");
75
139
  assert.equal(r.pid, 42);
76
- assert.equal(r.cmdline, "/usr/bin/python3 -m http.server 9222");
77
- });
78
- test("UNRELATED: ssOutput non-empty but no pid= token", () => {
79
- const r = classifyPortHolder({
80
- ssOutput: "Recv-Q Send-Q Local Address:Port Peer Address:Port",
81
- ownConfigDir: ".maxy",
82
- peerConfigDirs: peers(".maxy"),
83
- getCmdline: () => { throw new Error("should not be called"); },
84
- });
85
- assert.equal(r.kind, "UNRELATED");
86
- assert.equal(r.pid, undefined);
87
- });
88
- test("UNRELATED: getCmdline throws (race — process exited between ss and read)", () => {
89
- const r = classifyPortHolder({
90
- ssOutput: SS_PID_42,
91
- ownConfigDir: ".maxy",
92
- peerConfigDirs: peers(".maxy"),
93
- getCmdline: () => { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; },
94
- });
95
- assert.equal(r.kind, "UNRELATED");
96
- assert.equal(r.pid, 42);
97
- assert.equal(r.cmdlineReadFailed, true);
98
140
  });
99
- test("UNRELATED: --user-data-dir absent (no profile claim)", () => {
141
+ test("chromium UNRELATED: --user-data-dir absent (no profile claim)", () => {
100
142
  // Kernel threads, GPU/zygote/utility chrome processes inherit profile via
101
- // fork without re-stating --user-data-dir on their argv. They wouldn't be
102
- // listening anyway, but if one ever showed up here we must classify as
103
- // UNRELATED, never as OWN_BRAND on a substring fluke.
143
+ // fork without re-stating --user-data-dir. They wouldn't be listening
144
+ // anyway, but if one ever showed up here we must classify as UNRELATED.
104
145
  const cmdline = cmd("/opt/google/chrome/chrome", "--type=gpu-process", "--no-sandbox");
105
146
  const r = classifyPortHolder({
106
147
  ssOutput: SS_PID_42,
107
- ownConfigDir: ".maxy",
108
- peerConfigDirs: peers(".maxy"),
148
+ ownBrand: MAXY,
149
+ peerBrands: peers(MAXY),
109
150
  getCmdline: () => cmdline,
110
151
  });
111
152
  assert.equal(r.kind, "UNRELATED");
153
+ assert.equal(r.holderType, "chromium");
112
154
  });
113
- test("substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
155
+ test("chromium substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
114
156
  const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy-2/chromium-profile");
115
157
  const r = classifyPortHolder({
116
158
  ssOutput: SS_PID_42,
117
- ownConfigDir: ".maxy",
118
- peerConfigDirs: peers(".maxy"),
159
+ ownBrand: MAXY,
160
+ peerBrands: peers(MAXY),
119
161
  getCmdline: () => cmdline,
120
162
  });
121
163
  assert.equal(r.kind, "PEER_BRAND");
122
164
  assert.equal(r.profilePath, "/home/neo/.maxy-2/chromium-profile");
123
165
  });
124
- test("substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
166
+ test("chromium substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
125
167
  const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
126
168
  const r = classifyPortHolder({
127
169
  ssOutput: SS_PID_42,
128
- ownConfigDir: ".maxy-2",
129
- peerConfigDirs: peers(".maxy-2"),
170
+ ownBrand: MAXY_2,
171
+ peerBrands: peers(MAXY_2),
130
172
  getCmdline: () => cmdline,
131
173
  });
132
174
  assert.equal(r.kind, "PEER_BRAND");
133
175
  assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
134
176
  });
135
- test("argv-anchor safety: marker in --load-extension value must NOT classify as OWN_BRAND", () => {
136
- // Adversarial case from review: a Chrome flag whose value contains the
137
- // profile-path substring, but no actual --user-data-dir claim. Naive
138
- // substring matching would false-positive OWN_BRAND.
177
+ test("chromium argv-anchor safety: marker in --load-extension value must NOT match", () => {
178
+ // Adversarial case: a Chrome flag whose value contains the profile-path
179
+ // substring, but no actual --user-data-dir claim. Naive substring matching
180
+ // would false-positive OWN_BRAND.
139
181
  const cmdline = cmd("chromium", "--load-extension=/tmp/decoy/.maxy/chromium-profile", "--remote-debugging-port=9222");
140
182
  const r = classifyPortHolder({
141
183
  ssOutput: SS_PID_42,
142
- ownConfigDir: ".maxy",
143
- peerConfigDirs: peers(".maxy"),
184
+ ownBrand: MAXY,
185
+ peerBrands: peers(MAXY),
144
186
  getCmdline: () => cmdline,
145
187
  });
146
188
  assert.equal(r.kind, "UNRELATED");
147
189
  });
148
- test("ss header line containing 'pid=': last pid= wins", () => {
149
- // If a future ss locale prepends header text containing the literal `pid=`
150
- // (e.g., "Process pid="), the first-match heuristic would lift the wrong
151
- // value. The LISTEN row is always last, so taking the last pid= match is
152
- // the structural fix.
153
- const ssOutput = `State pid= notes\nLISTEN 0 4096 *:9222 *:* users:(("chrome",pid=42,fd=12))`;
154
- const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
190
+ test("chromium variant basenames: google-chrome-stable detected as chromium", () => {
191
+ // Ubuntu Noble laptop replaces snap-confined chromium with
192
+ // google-chrome-stable (Task 929). The classifier must still recognise
193
+ // it as a chromium-family holder.
194
+ const cmdline = cmd("/usr/bin/google-chrome-stable", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
155
195
  const r = classifyPortHolder({
156
- ssOutput,
157
- ownConfigDir: ".maxy",
158
- peerConfigDirs: peers(".maxy"),
159
- getCmdline: pid => { assert.equal(pid, 42, "must pick the LISTEN row's pid, not the header's"); return cmdline; },
196
+ ssOutput: SS_PID_42,
197
+ ownBrand: MAXY,
198
+ peerBrands: peers(MAXY),
199
+ getCmdline: () => cmdline,
200
+ });
201
+ assert.equal(r.kind, "OWN_BRAND");
202
+ assert.equal(r.holderType, "chromium");
203
+ });
204
+ // ---------------------------------------------------------------------------
205
+ // Xtigervnc (Task 939 — display-number anchor)
206
+ // ---------------------------------------------------------------------------
207
+ test("xtigervnc OWN_BRAND: argv[1] :99 matches own.vncDisplay", () => {
208
+ // Reproduces the laptop-maxy and Pi-maxy install-time log:
209
+ // cmdline: Xtigervnc :99 -geometry 1280x800 -depth 24 -rfbport 5900 …
210
+ const cmdline = cmd("Xtigervnc", ":99", "-geometry", "1280x800", "-depth", "24", "-rfbport", "5900", "-localhost", "-SecurityTypes", "None", "-AlwaysShared", "-BlacklistThreshold", "1000000");
211
+ const r = classifyPortHolder({
212
+ ssOutput: SS_PID_777,
213
+ ownBrand: MAXY,
214
+ peerBrands: peers(MAXY),
215
+ getCmdline: pid => { assert.equal(pid, 777); return cmdline; },
216
+ });
217
+ assert.equal(r.kind, "OWN_BRAND");
218
+ assert.equal(r.holderType, "xtigervnc");
219
+ assert.equal(r.pid, 777);
220
+ assert.equal(r.vncDisplay, 99);
221
+ });
222
+ test("xtigervnc PEER_BRAND: argv[1] :100 matches Real Agent vncDisplay when own is Maxy", () => {
223
+ const cmdline = cmd("Xtigervnc", ":100", "-rfbport", "5901");
224
+ const r = classifyPortHolder({
225
+ ssOutput: SS_PID_888,
226
+ ownBrand: MAXY,
227
+ peerBrands: peers(MAXY),
228
+ getCmdline: () => cmdline,
229
+ });
230
+ assert.equal(r.kind, "PEER_BRAND");
231
+ assert.equal(r.holderType, "xtigervnc");
232
+ assert.equal(r.vncDisplay, 100);
233
+ });
234
+ test("xtigervnc UNRELATED: argv display number matches no known brand", () => {
235
+ const cmdline = cmd("Xtigervnc", ":42", "-rfbport", "5942");
236
+ const r = classifyPortHolder({
237
+ ssOutput: SS_PID_777,
238
+ ownBrand: MAXY,
239
+ peerBrands: peers(MAXY),
240
+ getCmdline: () => cmdline,
241
+ });
242
+ assert.equal(r.kind, "UNRELATED");
243
+ assert.equal(r.holderType, "xtigervnc");
244
+ assert.equal(r.vncDisplay, 42);
245
+ });
246
+ test("xtigervnc UNRELATED: no `:N` token in argv", () => {
247
+ // Defence-in-depth: if argv parsing ever sees an Xtigervnc invocation
248
+ // with no display literal, classify UNRELATED rather than guessing.
249
+ const cmdline = cmd("Xtigervnc", "-help");
250
+ const r = classifyPortHolder({
251
+ ssOutput: SS_PID_777,
252
+ ownBrand: MAXY,
253
+ peerBrands: peers(MAXY),
254
+ getCmdline: () => cmdline,
255
+ });
256
+ assert.equal(r.kind, "UNRELATED");
257
+ assert.equal(r.holderType, "xtigervnc");
258
+ assert.equal(r.vncDisplay, undefined);
259
+ });
260
+ // ---------------------------------------------------------------------------
261
+ // websockify (Task 939 — bind-port anchor with full-argv scan for the binary)
262
+ // ---------------------------------------------------------------------------
263
+ test("websockify OWN_BRAND: bind port [::]:6080 matches own.websockifyPort (direct invocation)", () => {
264
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:6080", "localhost:5900");
265
+ const r = classifyPortHolder({
266
+ ssOutput: SS_PID_555,
267
+ ownBrand: MAXY,
268
+ peerBrands: peers(MAXY),
269
+ getCmdline: pid => { assert.equal(pid, 555); return cmdline; },
270
+ });
271
+ assert.equal(r.kind, "OWN_BRAND");
272
+ assert.equal(r.holderType, "websockify");
273
+ assert.equal(r.pid, 555);
274
+ assert.equal(r.websockifyPort, 6080);
275
+ });
276
+ test("websockify OWN_BRAND: detected via argv scan when invoked through python", () => {
277
+ // Pi Bookworm packages websockify as a python script with no shebang
278
+ // honoured by systemd in some configs — argv[0] may be `python3` and
279
+ // argv[1] = `/usr/bin/websockify`. The detector scans the full argv.
280
+ const cmdline = cmd("/usr/bin/python3", "/usr/bin/websockify", "--web", "/usr/share/novnc", "[::]:6080", "localhost:5900");
281
+ const r = classifyPortHolder({
282
+ ssOutput: SS_PID_555,
283
+ ownBrand: MAXY,
284
+ peerBrands: peers(MAXY),
285
+ getCmdline: () => cmdline,
160
286
  });
161
287
  assert.equal(r.kind, "OWN_BRAND");
288
+ assert.equal(r.holderType, "websockify");
289
+ assert.equal(r.websockifyPort, 6080);
290
+ });
291
+ test("websockify PEER_BRAND: bind port matches Real Agent websockifyPort when own is Maxy", () => {
292
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:6081", "localhost:5901");
293
+ const r = classifyPortHolder({
294
+ ssOutput: SS_PID_555,
295
+ ownBrand: MAXY,
296
+ peerBrands: peers(MAXY),
297
+ getCmdline: () => cmdline,
298
+ });
299
+ assert.equal(r.kind, "PEER_BRAND");
300
+ assert.equal(r.holderType, "websockify");
301
+ assert.equal(r.websockifyPort, 6081);
302
+ });
303
+ test("websockify UNRELATED: bind port matches no known brand", () => {
304
+ const cmdline = cmd("websockify", "--web", "/usr/share/novnc", "[::]:9999", "localhost:5900");
305
+ const r = classifyPortHolder({
306
+ ssOutput: SS_PID_555,
307
+ ownBrand: MAXY,
308
+ peerBrands: peers(MAXY),
309
+ getCmdline: () => cmdline,
310
+ });
311
+ assert.equal(r.kind, "UNRELATED");
312
+ assert.equal(r.holderType, "websockify");
313
+ assert.equal(r.websockifyPort, undefined);
314
+ });
315
+ // ---------------------------------------------------------------------------
316
+ // Unknown holder (defence-in-depth)
317
+ // ---------------------------------------------------------------------------
318
+ test("UNRELATED holderType=unknown: argv[0] is neither chromium, Xtigervnc, nor websockify", () => {
319
+ const cmdline = cmd("/usr/bin/python3", "-m", "http.server", "9222");
320
+ const r = classifyPortHolder({
321
+ ssOutput: SS_PID_42,
322
+ ownBrand: MAXY,
323
+ peerBrands: peers(MAXY),
324
+ getCmdline: () => cmdline,
325
+ });
326
+ assert.equal(r.kind, "UNRELATED");
327
+ assert.equal(r.holderType, "unknown");
162
328
  assert.equal(r.pid, 42);
329
+ assert.equal(r.cmdline, "/usr/bin/python3 -m http.server 9222");
163
330
  });
package/dist/index.js CHANGED
@@ -2457,12 +2457,13 @@ function installService() {
2457
2457
  // three brand-scoped ports is already held by a process that is NOT this
2458
2458
  // brand's own on-demand browser nor a peer brand's edge stack.
2459
2459
  //
2460
- // Classification (Task 938) reads `/proc/<pid>/cmdline` and matches on the
2461
- // user-data-dir profile path, not on `comm` regex. The previous
2462
- // `comm`-regex approach (`/Xtigervnc|websockify|chromium/`) failed on the
2463
- // laptop where Task 929 selects google-chrome-stable: comm is `chrome`,
2464
- // so the brand's own browser was misclassified UNRELATED and the install
2465
- // aborted against a port it could legitimately reclaim.
2460
+ // Classification (Task 938 chromium, Task 939 Xtigervnc + websockify) reads
2461
+ // `/proc/<pid>/cmdline` and applies a holder-specific argv anchor. Task 938
2462
+ // covered chromium-only via `--user-data-dir=`; Task 939 closed the gap on
2463
+ // Xtigervnc (no such flag anchor on the `:N` display literal) and
2464
+ // websockify (anchor on bind port). Brand identities (configDir,
2465
+ // vncDisplay, websockifyPort) come from brand-registry.json which the
2466
+ // bundler stamps at build time from every brands/<brand>/brand.json.
2466
2467
  //
2467
2468
  // Decisions per holder:
2468
2469
  // OWN_BRAND — SIGTERM, recheck, SIGKILL on stragglers, exit-1 only if
@@ -2471,9 +2472,42 @@ function installService() {
2471
2472
  // UNRELATED — refuse to write service files; emit operator override.
2472
2473
  // macOS dev hosts (no ss) fall through the catch and skip pre-flight
2473
2474
  // entirely — the runtime check in vnc.sh covers Linux production.
2474
- const peerConfigDirs = KNOWN_BRAND_HOSTNAMES
2475
- .filter(h => h !== BRAND.hostname)
2476
- .map(h => `.${h}`);
2475
+ const ownBrand = {
2476
+ configDir: BRAND.configDir,
2477
+ vncDisplay: VNC_DISPLAY,
2478
+ websockifyPort: WEBSOCKIFY_PORT_BRAND,
2479
+ };
2480
+ // Peer registry — load from payload/platform/config/brand-registry.json
2481
+ // when present (Task 939+ bundles). Older bundles ship without the
2482
+ // registry; in that case peerBrands stays empty. PEER_BRAND classification
2483
+ // for Xtigervnc/websockify is a defence-in-depth case anyway (port sets
2484
+ // are disjoint by Task 924), so the empty-list fallback is safe — peer
2485
+ // chromium will fall through to UNRELATED, matching pre-Task 938 behaviour
2486
+ // for the only realistic scenario (a stale peer browser on the wrong CDP
2487
+ // port).
2488
+ const peerBrands = (() => {
2489
+ const registryPath = join(PAYLOAD_DIR, "platform", "config", "brand-registry.json");
2490
+ if (!existsSync(registryPath)) {
2491
+ logFile(` [preflight] brand-registry.json not in payload — peer matching disabled`);
2492
+ return [];
2493
+ }
2494
+ try {
2495
+ const raw = JSON.parse(readFileSync(registryPath, "utf-8"));
2496
+ const entries = [];
2497
+ for (const b of raw.brands ?? []) {
2498
+ if (b.hostname === BRAND.hostname)
2499
+ continue;
2500
+ if (typeof b.configDir !== "string" || typeof b.vncDisplay !== "number" || typeof b.websockifyPort !== "number")
2501
+ continue;
2502
+ entries.push({ configDir: b.configDir, vncDisplay: b.vncDisplay, websockifyPort: b.websockifyPort });
2503
+ }
2504
+ return entries;
2505
+ }
2506
+ catch (err) {
2507
+ logFile(` [preflight] brand-registry.json parse failed: ${err instanceof Error ? err.message : String(err)} — peer matching disabled`);
2508
+ return [];
2509
+ }
2510
+ })();
2477
2511
  const ssReadHolder = (port) => {
2478
2512
  return execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
2479
2513
  encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
@@ -2513,8 +2547,22 @@ function installService() {
2513
2547
  }
2514
2548
  };
2515
2549
  const classify = (ssOutput) => classifyPortHolder({
2516
- ssOutput, ownConfigDir: BRAND.configDir, peerConfigDirs, getCmdline: readCmdline,
2550
+ ssOutput, ownBrand, peerBrands, getCmdline: readCmdline,
2517
2551
  });
2552
+ // Task 939 — log line varies by detected holder so the operator can see
2553
+ // which OWN_BRAND stack is being killed. The kill loop is identical for
2554
+ // all three holders (SIGTERM → 300ms → recheck → SIGKILL → recheck), so
2555
+ // only the announce line differs.
2556
+ const ownBrandAnnounceLine = (label, port, c) => {
2557
+ if (c.holderType === "xtigervnc") {
2558
+ return ` [preflight] ${label}=${port} held by OWN brand Xtigervnc display=:${c.vncDisplay} pid=${c.pid} — sending SIGTERM`;
2559
+ }
2560
+ if (c.holderType === "websockify") {
2561
+ return ` [preflight] ${label}=${port} held by OWN brand websockify pid=${c.pid} — sending SIGTERM`;
2562
+ }
2563
+ // Default — chromium / unknown OWN_BRAND
2564
+ return ` [preflight] ${label}=${port} held by OWN brand process pid=${c.pid} profile=${c.profilePath} — sending SIGTERM`;
2565
+ };
2518
2566
  const checkInstallPortFree = (label, port) => {
2519
2567
  let firstSsOutput;
2520
2568
  try {
@@ -2541,7 +2589,7 @@ function installService() {
2541
2589
  return;
2542
2590
  }
2543
2591
  if (r.kind === "OWN_BRAND" && r.pid !== undefined) {
2544
- logFile(` [preflight] ${label}=${port} held by OWN brand process pid=${r.pid} profile=${r.profilePath} — sending SIGTERM`);
2592
+ logFile(ownBrandAnnounceLine(label, port, r));
2545
2593
  killNoThrow(r.pid, "SIGTERM");
2546
2594
  sleepMs(300);
2547
2595
  const after = classify(ssReadOrAbort(label, port));