@rubytech/create-realagent 1.0.849 → 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.849",
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"
@@ -112,7 +112,7 @@ Present the admin SOUL via `render-component` with `name: "document-editor"` and
112
112
 
113
113
  After the admin SOUL is written and approved, call `onboarding-complete-step` with step 6.
114
114
 
115
- **Document ingestion.** If the user uploaded any documents during Step 6 (or earlier in the session), dispatch the `database-operator` subagent (via the universal `document-ingest` skill) to ingest them AFTER calling `onboarding-complete-step` — not before. Use the Agent tool with `run_in_background: true`. The critical path (SOUL file, step completion) must not depend on document ingestion succeeding. Include the document path, the document subject (typically the account owner's UserProfile or the LocalBusiness depending on the doc), and the scope in the brief. If no documents were uploaded, skip this step.
115
+ **Document ingestion.** If the user uploaded any documents during Step 6 (or earlier in the session), dispatch the `specialists:database-operator` subagent (via the universal `document-ingest` skill) to ingest them AFTER calling `onboarding-complete-step` — not before. Use the Agent tool with `run_in_background: true`. The critical path (SOUL file, step completion) must not depend on document ingestion succeeding. Include the document path, the document subject (typically the account owner's UserProfile or the LocalBusiness depending on the doc), and the scope in the brief. If no documents were uploaded, skip this step.
116
116
 
117
117
  **Next steps.** After completing onboarding, let the user know that everything configured during onboarding — plugins, WiFi, output style, thinking view, timezone, and personality — can be changed at any time through conversation. Then suggest three things the user can do next — all optional and available whenever they are ready:
118
118
 
@@ -195,7 +195,7 @@ After creation, no template metadata persists in the agent's files. The resultin
195
195
  9. **KNOWLEDGE.md generation** — populate from the now-tagged set plus keyword matches using the `update-knowledge` skill workflow
196
196
  10. Write `config.json` with selected model, plugins, status "active", `liveMemory`, and `knowledgeKeywords`. This is the last gated write — placed after IDENTITY.md, SOUL.md, and KNOWLEDGE.md to prevent cascade failure if one gate stalls.
197
197
  11. Check context budget — auto-summarise if over threshold
198
- 12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project`." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
198
+ 12. **Project the agent into the graph** — delegate to the `specialists:database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project`." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
199
199
  13. Confirm creation: "Agent created. Visitors can reach it at `/{slug}`"
200
200
 
201
201
  ### List
@@ -232,7 +232,7 @@ For knowledge scope changes:
232
232
  - Allow toggling `liveMemory` on/off (update `config.json`).
233
233
  - After changes, offer to refresh KNOWLEDGE.md using the `update-knowledge` skill.
234
234
 
235
- **After every Edit operation that touches IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY/`config.json` (including `liveMemory`, `knowledgeKeywords`, or direct-tag mutations), delegate to `database-operator` to re-project: POST `/api/admin/agents/{slug}/project`.** Without re-projection the on-disk files diverge from the graph state — the operator sees stale `:Agent` properties and stale `USES_KNOWLEDGE` edges in /graph. Loud-fail: surface non-2xx errors verbatim.
235
+ **After every Edit operation that touches IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY/`config.json` (including `liveMemory`, `knowledgeKeywords`, or direct-tag mutations), delegate to `specialists:database-operator` to re-project: POST `/api/admin/agents/{slug}/project`.** Without re-projection the on-disk files diverge from the graph state — the operator sees stale `:Agent` properties and stale `USES_KNOWLEDGE` edges in /graph. Loud-fail: surface non-2xx errors verbatim.
236
236
 
237
237
  ### Delete
238
238
 
@@ -93,6 +93,6 @@ The constraint is computed once per turn at the top of `invokeAgent` and frozen
93
93
 
94
94
  ## Limits and deferrals
95
95
 
96
- v1 covers the admin agent only. Specialist subagents (`personal-assistant`, `project-manager`, `research-assistant`, `content-producer`, `database-operator`) do not receive their own ledger injection yet — their `.md` templates load via `--plugin-dir` and have no TS-side assembly site. Follow-up task filed.
96
+ v1 covers the admin agent only. Specialist subagents (`specialists:personal-assistant`, `specialists:project-manager`, `specialists:research-assistant`, `specialists:content-producer`, `specialists:database-operator`) do not receive their own ledger injection yet — their `.md` templates load via `--plugin-dir` and have no TS-side assembly site. Follow-up task filed.
97
97
 
98
98
  No cross-agent rule inheritance, no user-visible correction-ack signal, no blocking-critic retry loop in v1 — each is a separate follow-up task. See [`.docs/agents.md`](../../../../.docs/agents.md) § Adherence Fidelity for the full deferral list with task numbers.
@@ -10,11 +10,11 @@ metadata: {"platform":{"optional":true,"pluginKey":"linkedin-import"}}
10
10
 
11
11
  # LinkedIn Import
12
12
 
13
- Ingests a LinkedIn Basic Data Export (unzipped directory of CSVs + subdirectories) into the Maxy Neo4j graph. Skill-only plugin — no MCP server, no admin tools added. The skill runs under the `database-operator` specialist, which owns external-archive ingestion and ad-hoc graph operations.
13
+ Ingests a LinkedIn Basic Data Export (unzipped directory of CSVs + subdirectories) into the Maxy Neo4j graph. Skill-only plugin — no MCP server, no admin tools added. The skill runs under the `specialists:database-operator` specialist, which owns external-archive ingestion and ad-hoc graph operations.
14
14
 
15
15
  ## When this applies
16
16
 
17
- The admin agent delegates to `database-operator` when the operator drops a `Basic_LinkedInDataExport_*` directory (or references one by path) into chat. The specialist runs the skill's archive-owner confirmation flow before any CSV is read, then ingests the CSVs the skill has references for.
17
+ The admin agent delegates to `specialists:database-operator` when the operator drops a `Basic_LinkedInDataExport_*` directory (or references one by path) into chat. The specialist runs the skill's archive-owner confirmation flow before any CSV is read, then ingests the CSVs the skill has references for.
18
18
 
19
19
  ## Intra-plugin growth
20
20
 
@@ -110,7 +110,7 @@ Rows missing a position but present with a company produce a `WORKS_FOR` edge wi
110
110
 
111
111
  ## Post-import verification (operator-side, not agent-side)
112
112
 
113
- After ingest, the operator can verify counts via the `database-operator` specialist's read tools — `mcp__memory__memory-search` with `labels: ["Person"]` plus a filter, or a direct read query through `mcp__graph__maxy-graph-read_neo4j_cypher`:
113
+ After ingest, the operator can verify counts via the `specialists:database-operator` specialist's read tools — `mcp__memory__memory-search` with `labels: ["Person"]` plus a filter, or a direct read query through `mcp__graph__maxy-graph-read_neo4j_cypher`:
114
114
 
115
115
  ```cypher
116
116
  // Owner → connections count
@@ -132,4 +132,4 @@ These are **read queries**, not writes. Cypher writes from the agent are forbidd
132
132
  | Tool error "row connectedOn is not ISO 8601" | Parser left `Connected On` in `"23 Apr 2026"` form | Convert to `YYYY-MM-DD` before passing to the tool |
133
133
  | Tool error "ownerNodeId not found" | Owner-confirmation flow not run, or operator typed the wrong id | Re-run owner confirmation; pass the resulting `elementId` as `ownerNodeId` |
134
134
  | `WORKS_FOR` count « connection count | Many rows have blank company | Expected — LinkedIn doesn't force connections to list a current employer |
135
- | Tool not present in `init` frame | `database-operator` spawned without the `mcp__memory__memory-archive-write` token | Loud-fail per database-operator's prerogatives. Do not improvise via Bash. Operator must remediate (re-seed specialist templates) |
135
+ | Tool not present in `init` frame | `specialists:database-operator` spawned without the `mcp__memory__memory-archive-write` token | Loud-fail per database-operator's prerogatives. Do not improvise via Bash. Operator must remediate (re-seed specialist templates) |
@@ -109,7 +109,7 @@ Before any structured write, load `references/schema-base.md` via `plugin-read`.
109
109
 
110
110
  ## Document Ingestion
111
111
 
112
- Document ingestion of any kind — PDFs, text, transcripts, web pages, single files — routes to the `database-operator` specialist, which loads the universal `document-ingest` skill at `skills/document-ingest/SKILL.md`. The admin agent never calls `memory-ingest` directly; it dispatches with the document path, the document subject (the anchor node), and the visibility scope.
112
+ Document ingestion of any kind — PDFs, text, transcripts, web pages, single files — routes to the `specialists:database-operator` specialist, which loads the universal `document-ingest` skill at `skills/document-ingest/SKILL.md`. The admin agent never calls `memory-ingest` directly; it dispatches with the document path, the document subject (the anchor node), and the visibility scope.
113
113
 
114
114
  ### Pipeline
115
115
 
@@ -206,7 +206,7 @@ The classifier returns `kind` strings from the closed enumeration above. `kind`
206
206
  | `TO` | `KnowledgeDocument` → `Person` or `Organization` | Email direct recipient — written from `documentEdges` per recipient. |
207
207
  | `CC` | `KnowledgeDocument` → `Person` or `Organization` | Email cc'd recipient — written from `documentEdges` per cc. |
208
208
  | `SPEAKER` | `KnowledgeDocument` → `Person` | Voice-note / single-speaker transcript speaker — written from `documentEdges`. |
209
- | `MENTIONS` | `KnowledgeDocument` → `Person`, `Organization`, `Service`, `Task`, `Event`, `KnowledgeDocument`, `BrandingData` | Catch-all for entities the dispatch brief named that the document references but for which no document-shape-specific edge applies — written by `database-operator` in the `wire-brief-entities` pipeline step. |
209
+ | `MENTIONS` | `KnowledgeDocument` → `Person`, `Organization`, `Service`, `Task`, `Event`, `KnowledgeDocument`, `BrandingData` | Catch-all for entities the dispatch brief named that the document references but for which no document-shape-specific edge applies — written by `specialists:database-operator` in the `wire-brief-entities` pipeline step. |
210
210
  | `DEFINES` | `Section:Definitions` → `DefinedTerm` | Contract definitions — written from per-section `related`. |
211
211
 
212
212
  **Ontology-growth review query.** When a document accumulates several `:Section:Other` nodes, the operator (or admin agent) can run the following Cypher to surface candidate ontology additions:
@@ -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:
@@ -255,6 +255,10 @@ The platform also operates an api-wait-ping liveness gate: a heartbeat-driven st
255
255
 
256
256
  In managed context mode, conversation history is provided within `<conversation-history>` tags. Use `session-compact-status` to retrieve older archived context if needed.
257
257
 
258
+ ## Thread resumption after a sub-flow
259
+
260
+ A "sub-flow" is a self-contained run of tool calls — identity repair, LEARNINGS edit, schema audit, attachment unzip — whose subject is not the operator's last stated intent. After the sub-flow returns, the conversation history still holds the operator's earlier request in full. Resume that thread yourself: name what they were asking, then pick it back up. Do not ask the operator to restate, remind, or repeat the intent. Phrases like "What were you originally asking …", "What did you want me to …", or "Remind me what …" delegate the work of holding the thread to the operator and signal that you have treated the prior turn as if it were wiped — when it was not. The exhibit for this rule is the L:2611 turn in stream-4683bd3f, where an OWNS-edge repair returned successfully and the next sentence asked the operator to restate the calendar question they had been pursuing for a thousand prior lines. The platform emits a `[thread-resumption] kind=restate-request` log line when the post-tool assistant text matches one of those phrases, so this regression class is now visible in operations logs even when the operator does not flag it.
261
+
258
262
  ## Tasks
259
263
 
260
264
  Tasks live in the graph — not in files. The tasks plugin manages them.