@rubytech/create-realagent 1.0.850 → 1.0.852
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Task 938 — acceptance grid for classifyPortHolder.
|
|
2
|
+
//
|
|
3
|
+
// The wrapper in index.ts owns ss(8), /proc reads, kill(2), and the operator-
|
|
4
|
+
// 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.
|
|
6
|
+
//
|
|
7
|
+
// 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.
|
|
11
|
+
//
|
|
12
|
+
// Runs via Node's built-in test runner — same convention as
|
|
13
|
+
// peer-brand-detect.test.ts.
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
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
|
+
const SS_PID_42 = "LISTEN 0 4096 *:9222 *:* users:((\"chrome\",pid=42,fd=12))";
|
|
20
|
+
const SS_PID_99 = "LISTEN 0 4096 *:9223 *:* users:((\"chrome\",pid=99,fd=12))";
|
|
21
|
+
const cmd = (...argv) => argv.join("\0") + "\0";
|
|
22
|
+
test("empty ssOutput → EMPTY", () => {
|
|
23
|
+
const r = classifyPortHolder({
|
|
24
|
+
ssOutput: "",
|
|
25
|
+
ownConfigDir: ".maxy",
|
|
26
|
+
peerConfigDirs: peers(".maxy"),
|
|
27
|
+
getCmdline: () => { throw new Error("should not be called"); },
|
|
28
|
+
});
|
|
29
|
+
assert.equal(r.kind, "EMPTY");
|
|
30
|
+
});
|
|
31
|
+
test("OWN_BRAND: --user-data-dir=PATH single argv", () => {
|
|
32
|
+
const cmdline = cmd("/opt/google/chrome/chrome", "--user-data-dir=/home/neo/.maxy/chromium-profile", "--remote-debugging-port=9222");
|
|
33
|
+
const r = classifyPortHolder({
|
|
34
|
+
ssOutput: SS_PID_42,
|
|
35
|
+
ownConfigDir: ".maxy",
|
|
36
|
+
peerConfigDirs: peers(".maxy"),
|
|
37
|
+
getCmdline: pid => { assert.equal(pid, 42); return cmdline; },
|
|
38
|
+
});
|
|
39
|
+
assert.equal(r.kind, "OWN_BRAND");
|
|
40
|
+
assert.equal(r.pid, 42);
|
|
41
|
+
assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
|
|
42
|
+
});
|
|
43
|
+
test("OWN_BRAND: --user-data-dir PATH split across two argvs", () => {
|
|
44
|
+
const cmdline = cmd("/usr/bin/chromium", "--user-data-dir", "/home/admin/.maxy/chromium-profile", "--remote-debugging-port=9222");
|
|
45
|
+
const r = classifyPortHolder({
|
|
46
|
+
ssOutput: SS_PID_42,
|
|
47
|
+
ownConfigDir: ".maxy",
|
|
48
|
+
peerConfigDirs: peers(".maxy"),
|
|
49
|
+
getCmdline: () => cmdline,
|
|
50
|
+
});
|
|
51
|
+
assert.equal(r.kind, "OWN_BRAND");
|
|
52
|
+
assert.equal(r.profilePath, "/home/admin/.maxy/chromium-profile");
|
|
53
|
+
});
|
|
54
|
+
test("PEER_BRAND: cmdline holds another known brand's profile path", () => {
|
|
55
|
+
const cmdline = cmd("/usr/bin/chromium", "--user-data-dir=/home/admin/.realagent/chromium-profile", "--remote-debugging-port=9223");
|
|
56
|
+
const r = classifyPortHolder({
|
|
57
|
+
ssOutput: SS_PID_99,
|
|
58
|
+
ownConfigDir: ".maxy",
|
|
59
|
+
peerConfigDirs: peers(".maxy"),
|
|
60
|
+
getCmdline: () => cmdline,
|
|
61
|
+
});
|
|
62
|
+
assert.equal(r.kind, "PEER_BRAND");
|
|
63
|
+
assert.equal(r.pid, 99);
|
|
64
|
+
assert.equal(r.profilePath, "/home/admin/.realagent/chromium-profile");
|
|
65
|
+
});
|
|
66
|
+
test("UNRELATED: cmdline matches no known brand profile", () => {
|
|
67
|
+
const cmdline = cmd("/usr/bin/python3", "-m", "http.server", "9222");
|
|
68
|
+
const r = classifyPortHolder({
|
|
69
|
+
ssOutput: SS_PID_42,
|
|
70
|
+
ownConfigDir: ".maxy",
|
|
71
|
+
peerConfigDirs: peers(".maxy"),
|
|
72
|
+
getCmdline: () => cmdline,
|
|
73
|
+
});
|
|
74
|
+
assert.equal(r.kind, "UNRELATED");
|
|
75
|
+
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
|
+
});
|
|
99
|
+
test("UNRELATED: --user-data-dir absent (no profile claim)", () => {
|
|
100
|
+
// 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.
|
|
104
|
+
const cmdline = cmd("/opt/google/chrome/chrome", "--type=gpu-process", "--no-sandbox");
|
|
105
|
+
const r = classifyPortHolder({
|
|
106
|
+
ssOutput: SS_PID_42,
|
|
107
|
+
ownConfigDir: ".maxy",
|
|
108
|
+
peerConfigDirs: peers(".maxy"),
|
|
109
|
+
getCmdline: () => cmdline,
|
|
110
|
+
});
|
|
111
|
+
assert.equal(r.kind, "UNRELATED");
|
|
112
|
+
});
|
|
113
|
+
test("substring boundary: own=.maxy must NOT match .maxy-2 cmdline", () => {
|
|
114
|
+
const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy-2/chromium-profile");
|
|
115
|
+
const r = classifyPortHolder({
|
|
116
|
+
ssOutput: SS_PID_42,
|
|
117
|
+
ownConfigDir: ".maxy",
|
|
118
|
+
peerConfigDirs: peers(".maxy"),
|
|
119
|
+
getCmdline: () => cmdline,
|
|
120
|
+
});
|
|
121
|
+
assert.equal(r.kind, "PEER_BRAND");
|
|
122
|
+
assert.equal(r.profilePath, "/home/neo/.maxy-2/chromium-profile");
|
|
123
|
+
});
|
|
124
|
+
test("substring boundary: own=.maxy-2 must NOT misclassify .maxy cmdline as own", () => {
|
|
125
|
+
const cmdline = cmd("chromium", "--user-data-dir=/home/neo/.maxy/chromium-profile");
|
|
126
|
+
const r = classifyPortHolder({
|
|
127
|
+
ssOutput: SS_PID_42,
|
|
128
|
+
ownConfigDir: ".maxy-2",
|
|
129
|
+
peerConfigDirs: peers(".maxy-2"),
|
|
130
|
+
getCmdline: () => cmdline,
|
|
131
|
+
});
|
|
132
|
+
assert.equal(r.kind, "PEER_BRAND");
|
|
133
|
+
assert.equal(r.profilePath, "/home/neo/.maxy/chromium-profile");
|
|
134
|
+
});
|
|
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.
|
|
139
|
+
const cmdline = cmd("chromium", "--load-extension=/tmp/decoy/.maxy/chromium-profile", "--remote-debugging-port=9222");
|
|
140
|
+
const r = classifyPortHolder({
|
|
141
|
+
ssOutput: SS_PID_42,
|
|
142
|
+
ownConfigDir: ".maxy",
|
|
143
|
+
peerConfigDirs: peers(".maxy"),
|
|
144
|
+
getCmdline: () => cmdline,
|
|
145
|
+
});
|
|
146
|
+
assert.equal(r.kind, "UNRELATED");
|
|
147
|
+
});
|
|
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");
|
|
155
|
+
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; },
|
|
160
|
+
});
|
|
161
|
+
assert.equal(r.kind, "OWN_BRAND");
|
|
162
|
+
assert.equal(r.pid, 42);
|
|
163
|
+
});
|
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,141 @@ 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) 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.
|
|
2466
|
+
//
|
|
2467
|
+
// Decisions per holder:
|
|
2468
|
+
// OWN_BRAND — SIGTERM, recheck, SIGKILL on stragglers, exit-1 only if
|
|
2469
|
+
// the port is still held after both signals.
|
|
2470
|
+
// PEER_BRAND — log OK and return (per-brand port sets are disjoint).
|
|
2471
|
+
// UNRELATED — refuse to write service files; emit operator override.
|
|
2472
|
+
// macOS dev hosts (no ss) fall through the catch and skip pre-flight
|
|
2473
|
+
// 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}`);
|
|
2477
|
+
const ssReadHolder = (port) => {
|
|
2478
|
+
return execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
|
|
2479
|
+
encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
|
|
2480
|
+
});
|
|
2481
|
+
};
|
|
2482
|
+
// Pass raw NUL-separated cmdline to the classifier so it can argv-anchor
|
|
2483
|
+
// on `--user-data-dir=`. Replacing NUL with space here would defeat that.
|
|
2484
|
+
const readCmdline = (pid) => readFileSync(`/proc/${pid}/cmdline`, "utf-8");
|
|
2485
|
+
const sleepMs = (ms) => { spawnSync("sleep", [(ms / 1000).toString()]); };
|
|
2486
|
+
// Tightly scoped variant for retry-path ss reads. Failures here (timeout,
|
|
2487
|
+
// ENOMEM, signal) are structural — never the macOS-no-ss case (we already
|
|
2488
|
+
// succeeded once) — so they get a structured exit, not a stack trace.
|
|
2489
|
+
const ssReadOrAbort = (label, port) => {
|
|
2464
2490
|
try {
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
return;
|
|
2471
|
-
const isPeerBrandStack = /Xtigervnc/.test(heldBy) || /websockify/.test(heldBy) || /chromium/.test(heldBy);
|
|
2472
|
-
if (isPeerBrandStack) {
|
|
2473
|
-
logFile(` [preflight] ${label}=${port} held by a peer brand's stack — OK (per-brand ports are disjoint by construction)`);
|
|
2474
|
-
return;
|
|
2475
|
-
}
|
|
2476
|
-
console.error(` ERROR: [preflight:collision] brand=${BRAND.hostname} ${label}=${port} held by an unrelated process:`);
|
|
2477
|
-
console.error(` ${heldBy}`);
|
|
2478
|
-
console.error(` Refusing to write service files; resolve the collision before retrying.`);
|
|
2479
|
-
console.error(` Operator override: edit brands/${BRAND.hostname}/brand.json and set ${label}=<free port>, re-bundle, re-install.`);
|
|
2491
|
+
return ssReadHolder(port);
|
|
2492
|
+
}
|
|
2493
|
+
catch (err) {
|
|
2494
|
+
console.error(` ERROR: [preflight] ${label}=${port} ss recheck failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2495
|
+
console.error(` Resolve manually before retrying.`);
|
|
2480
2496
|
process.exit(1);
|
|
2481
2497
|
}
|
|
2498
|
+
};
|
|
2499
|
+
// Distinguish ESRCH (process already gone — expected) from EPERM/EINVAL
|
|
2500
|
+
// (alarming — signals we can't deliver, possibly a recycled pid). Returns
|
|
2501
|
+
// true for clean kill or ESRCH, false otherwise (caller logs a warning).
|
|
2502
|
+
const killNoThrow = (pid, signal) => {
|
|
2503
|
+
try {
|
|
2504
|
+
process.kill(pid, signal);
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
2507
|
+
catch (err) {
|
|
2508
|
+
const code = err.code;
|
|
2509
|
+
if (code === "ESRCH")
|
|
2510
|
+
return true;
|
|
2511
|
+
logFile(` [preflight] kill(${pid}, ${signal}) failed code=${code ?? "unknown"}`);
|
|
2512
|
+
return false;
|
|
2513
|
+
}
|
|
2514
|
+
};
|
|
2515
|
+
const classify = (ssOutput) => classifyPortHolder({
|
|
2516
|
+
ssOutput, ownConfigDir: BRAND.configDir, peerConfigDirs, getCmdline: readCmdline,
|
|
2517
|
+
});
|
|
2518
|
+
const checkInstallPortFree = (label, port) => {
|
|
2519
|
+
let firstSsOutput;
|
|
2520
|
+
try {
|
|
2521
|
+
firstSsOutput = ssReadHolder(port);
|
|
2522
|
+
}
|
|
2482
2523
|
catch (err) {
|
|
2483
2524
|
// ss may not be present on macOS dev hosts — skip the pre-flight there
|
|
2484
2525
|
// rather than abort the install. The runtime check in vnc.sh covers
|
|
2485
|
-
// production-like Linux installs where this matters.
|
|
2526
|
+
// production-like Linux installs where this matters. This catch is
|
|
2527
|
+
// narrow on purpose: only the first ss invocation may legitimately
|
|
2528
|
+
// fail (binary missing); retry-path failures use ssReadOrAbort.
|
|
2486
2529
|
logFile(` [preflight] ${label}=${port} check skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
2530
|
+
return;
|
|
2487
2531
|
}
|
|
2532
|
+
let r = classify(firstSsOutput);
|
|
2533
|
+
// ENOENT race — process exited between ss and cmdline read. Port is
|
|
2534
|
+
// probably free now; one re-check resolves it deterministically.
|
|
2535
|
+
if (r.cmdlineReadFailed)
|
|
2536
|
+
r = classify(ssReadOrAbort(label, port));
|
|
2537
|
+
if (r.kind === "EMPTY")
|
|
2538
|
+
return;
|
|
2539
|
+
if (r.kind === "PEER_BRAND") {
|
|
2540
|
+
logFile(` [preflight] ${label}=${port} held by a peer brand's stack — OK (per-brand ports are disjoint by construction)`);
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
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`);
|
|
2545
|
+
killNoThrow(r.pid, "SIGTERM");
|
|
2546
|
+
sleepMs(300);
|
|
2547
|
+
const after = classify(ssReadOrAbort(label, port));
|
|
2548
|
+
if (after.kind === "EMPTY") {
|
|
2549
|
+
logFile(` [preflight] ${label}=${port} freed`);
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
if (after.kind === "OWN_BRAND" && after.pid === r.pid) {
|
|
2553
|
+
logFile(` [preflight] ${label}=${port} survived SIGTERM — sending SIGKILL`);
|
|
2554
|
+
killNoThrow(r.pid, "SIGKILL");
|
|
2555
|
+
sleepMs(300);
|
|
2556
|
+
const final = classify(ssReadOrAbort(label, port));
|
|
2557
|
+
if (final.kind === "EMPTY") {
|
|
2558
|
+
logFile(` [preflight] ${label}=${port} freed`);
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
console.error(` ERROR: [preflight] ${label}=${port} OWN_BRAND auto-kill failed pid=${r.pid} — resolve manually before retrying.`);
|
|
2562
|
+
process.exit(1);
|
|
2563
|
+
}
|
|
2564
|
+
// A different OWN_BRAND pid took the port. The brand's user services
|
|
2565
|
+
// are respawning Chromium — installer cannot win this race. Stop the
|
|
2566
|
+
// services, then retry.
|
|
2567
|
+
if (after.kind === "OWN_BRAND") {
|
|
2568
|
+
console.error(` ERROR: [preflight] ${label}=${port} brand respawned a new OWN_BRAND pid (was ${r.pid}, now ${after.pid}).`);
|
|
2569
|
+
console.error(` Stop the brand's user services first: \`systemctl --user stop ${BRAND.hostname}-edge ${BRAND.hostname}\`, then re-run the installer.`);
|
|
2570
|
+
process.exit(1);
|
|
2571
|
+
}
|
|
2572
|
+
// PEER_BRAND or UNRELATED took the port post-kill — fall through to
|
|
2573
|
+
// those branches by re-classifying the surviving holder.
|
|
2574
|
+
r = after;
|
|
2575
|
+
if (r.kind === "EMPTY")
|
|
2576
|
+
return;
|
|
2577
|
+
if (r.kind === "PEER_BRAND") {
|
|
2578
|
+
logFile(` [preflight] ${label}=${port} now held by a peer brand's stack — OK`);
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
// r.kind === "UNRELATED" — fall through to the operator-override block.
|
|
2582
|
+
}
|
|
2583
|
+
// UNRELATED — preserve the operator-override path verbatim.
|
|
2584
|
+
console.error(` ERROR: [preflight:collision] brand=${BRAND.hostname} ${label}=${port} held by an unrelated process:`);
|
|
2585
|
+
console.error(` ${firstSsOutput.trim()}`);
|
|
2586
|
+
if (r.cmdline)
|
|
2587
|
+
console.error(` cmdline: ${r.cmdline}`);
|
|
2588
|
+
console.error(` Refusing to write service files; resolve the collision before retrying.`);
|
|
2589
|
+
console.error(` Operator override: edit brands/${BRAND.hostname}/brand.json, set/add \`${label}\` to a free port, re-bundle, re-install.`);
|
|
2590
|
+
process.exit(1);
|
|
2488
2591
|
};
|
|
2489
2592
|
checkInstallPortFree("rfbPort", RFB_PORT);
|
|
2490
2593
|
checkInstallPortFree("websockifyPort", WEBSOCKIFY_PORT_BRAND);
|
|
@@ -2631,8 +2734,8 @@ WantedBy=multi-user.target
|
|
|
2631
2734
|
console.log(" [cdp-check] skipped reason=native-display (on-demand Chromium)");
|
|
2632
2735
|
}
|
|
2633
2736
|
else {
|
|
2634
|
-
console.log(
|
|
2635
|
-
const cdpCheck = spawnSync("curl", ["-sf",
|
|
2737
|
+
console.log(` Verifying browser automation (CDP on port ${CDP_PORT_BRAND})...`);
|
|
2738
|
+
const cdpCheck = spawnSync("curl", ["-sf", `http://127.0.0.1:${CDP_PORT_BRAND}/json/version`, "-o", "/dev/null"], {
|
|
2636
2739
|
timeout: 5000,
|
|
2637
2740
|
stdio: "pipe",
|
|
2638
2741
|
});
|
|
@@ -2649,8 +2752,8 @@ WantedBy=multi-user.target
|
|
|
2649
2752
|
vncLog = `(no boot log found at ${vncLogPath})`;
|
|
2650
2753
|
}
|
|
2651
2754
|
console.error("");
|
|
2652
|
-
console.error(
|
|
2653
|
-
console.error(
|
|
2755
|
+
console.error(`Setup failed: Browser automation unavailable — CDP port ${CDP_PORT_BRAND} not responding`);
|
|
2756
|
+
console.error(` ERROR: Browser automation unavailable — CDP port ${CDP_PORT_BRAND} not responding.`);
|
|
2654
2757
|
console.error(" Chromium should be started by vnc.sh (ExecStartPre). Check the boot log:");
|
|
2655
2758
|
console.error("");
|
|
2656
2759
|
console.error(vncLog);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Task 938 — pure classifier for the install-time port collision pre-flight.
|
|
2
|
+
// Extracted from index.ts so the OWN_BRAND / PEER_BRAND / UNRELATED decision
|
|
3
|
+
// can be unit-tested without ss(8), /proc, or kill(2). Mirrors the
|
|
4
|
+
// peer-brand-detect.ts pattern: inputs in, classification out, no I/O.
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// Rule, in order of precedence:
|
|
11
|
+
// 1. ssOutput is empty → EMPTY
|
|
12
|
+
// 2. No `pid=N` token in ssOutput → UNRELATED (pid undef)
|
|
13
|
+
// 3. getCmdline(pid) throws → UNRELATED (cmdlineReadFailed)
|
|
14
|
+
// 4. argv has --user-data-dir=PATH where PATH contains
|
|
15
|
+
// `/<ownConfigDir>/chromium-profile` → OWN_BRAND
|
|
16
|
+
// 5. same for any `<peerConfigDir>` → PEER_BRAND
|
|
17
|
+
// 6. otherwise → UNRELATED
|
|
18
|
+
//
|
|
19
|
+
// Why argv parsing instead of substring matching: `cmdline.includes('/.maxy/
|
|
20
|
+
// chromium-profile')` would false-match `--load-extension=/tmp/.maxy/chromium-
|
|
21
|
+
// profile` (or any other flag whose value happens to contain the profile path
|
|
22
|
+
// component). Anchoring on `--user-data-dir=` is the only signal that this
|
|
23
|
+
// process actually owns that profile directory.
|
|
24
|
+
//
|
|
25
|
+
// Why "last pid=" instead of "first pid=": ss with `-tlnpH sport = :PORT`
|
|
26
|
+
// emits the LISTEN row last; if a future ss locale or release prepends a
|
|
27
|
+
// header line that contains `pid=` text, the first-match heuristic would
|
|
28
|
+
// lift the wrong pid. The last `pid=` is always inside the LISTEN row's
|
|
29
|
+
// `users:((…))` segment.
|
|
30
|
+
/**
|
|
31
|
+
* `cmdline` should be the raw `/proc/<pid>/cmdline` contents — NUL-separated
|
|
32
|
+
* argv as the kernel emits it. Do NOT replace NUL with space before passing:
|
|
33
|
+
* the argv boundaries are what disambiguate `--user-data-dir=PATH` from a
|
|
34
|
+
* different flag whose value happens to contain `--user-data-dir=`.
|
|
35
|
+
*/
|
|
36
|
+
export function classifyPortHolder(args) {
|
|
37
|
+
const { ssOutput, ownConfigDir, peerConfigDirs, getCmdline } = args;
|
|
38
|
+
if (ssOutput.trim() === "")
|
|
39
|
+
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
|
+
const pidMatches = [...ssOutput.matchAll(/pid=(\d+)/g)];
|
|
43
|
+
if (pidMatches.length === 0)
|
|
44
|
+
return { kind: "UNRELATED" };
|
|
45
|
+
const pid = Number(pidMatches[pidMatches.length - 1][1]);
|
|
46
|
+
let cmdline;
|
|
47
|
+
try {
|
|
48
|
+
cmdline = getCmdline(pid);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { kind: "UNRELATED", pid, cmdlineReadFailed: true };
|
|
52
|
+
}
|
|
53
|
+
// Pretty cmdline (NULs → spaces) for log output. The argv-aware matching
|
|
54
|
+
// operates on the raw NUL-separated form.
|
|
55
|
+
const prettyCmdline = cmdline.replace(/\0/g, " ").trim();
|
|
56
|
+
const argv = cmdline.split("\0").filter(s => s.length > 0);
|
|
57
|
+
const userDataDir = findUserDataDir(argv);
|
|
58
|
+
if (userDataDir === null) {
|
|
59
|
+
return { kind: "UNRELATED", pid, cmdline: prettyCmdline };
|
|
60
|
+
}
|
|
61
|
+
const ownSuffix = `/${ownConfigDir}/chromium-profile`;
|
|
62
|
+
if (userDataDir.includes(ownSuffix)) {
|
|
63
|
+
return { kind: "OWN_BRAND", pid, cmdline: prettyCmdline, profilePath: userDataDir };
|
|
64
|
+
}
|
|
65
|
+
for (const peerCD of peerConfigDirs) {
|
|
66
|
+
const peerSuffix = `/${peerCD}/chromium-profile`;
|
|
67
|
+
if (userDataDir.includes(peerSuffix)) {
|
|
68
|
+
return { kind: "PEER_BRAND", pid, cmdline: prettyCmdline, profilePath: userDataDir };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { kind: "UNRELATED", pid, cmdline: prettyCmdline };
|
|
72
|
+
}
|
|
73
|
+
// Find the value of `--user-data-dir`, supporting both `--user-data-dir=PATH`
|
|
74
|
+
// (single argv) and `--user-data-dir PATH` (split across two argvs). Returns
|
|
75
|
+
// null if the flag is absent.
|
|
76
|
+
function findUserDataDir(argv) {
|
|
77
|
+
const FLAG = "--user-data-dir";
|
|
78
|
+
const PREFIX = `${FLAG}=`;
|
|
79
|
+
for (let i = 0; i < argv.length; i++) {
|
|
80
|
+
const a = argv[i];
|
|
81
|
+
if (a.startsWith(PREFIX))
|
|
82
|
+
return a.slice(PREFIX.length);
|
|
83
|
+
if (a === FLAG && i + 1 < argv.length)
|
|
84
|
+
return argv[i + 1];
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -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:
|