@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 a
2457
- // peer brand's edge service. Collisions with peer brands' Xtigervnc or
2458
- // websockify on this device are normal in concurrent multi-brand
2459
- // installs and harmless because each brand's port set is disjoint by
2460
- // construction. Anything else (a stale pre-924 unit, an external VNC,
2461
- // or operator misconfiguration) is a structural collision the operator
2462
- // must resolve before service start.
2463
- const checkInstallPortFree = (label, port) => {
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
- const out = execFileSync("ss", ["-tlnpH", `sport = :${port}`], {
2466
- encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"],
2467
- });
2468
- const heldBy = out.trim();
2469
- if (!heldBy)
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(" Verifying browser automation (CDP on port 9222)...");
2635
- const cdpCheck = spawnSync("curl", ["-sf", "http://127.0.0.1:9222/json/version", "-o", "/dev/null"], {
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("Setup failed: Browser automation unavailable — CDP port 9222 not responding");
2653
- console.error(" ERROR: Browser automation unavailable — CDP port 9222 not responding.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-realagent",
3
- "version": "1.0.850",
3
+ "version": "1.0.852",
4
4
  "description": "Install Real Agent — Built for agents. By agents.",
5
5
  "bin": {
6
6
  "create-realagent": "./dist/index.js"
@@ -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 port 9222 (started by `vnc.sh`, always running) |
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: