@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.
- package/dist/__tests__/preflight-port-classifier.test.js +163 -0
- package/dist/index.js +132 -29
- package/dist/preflight-port-classifier.js +87 -0
- package/package.json +1 -1
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +1 -1
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
- package/payload/platform/plugins/docs/references/adherence.md +1 -1
- package/payload/platform/plugins/linkedin-import/PLUGIN.md +2 -2
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +2 -2
- package/payload/platform/plugins/memory/PLUGIN.md +1 -1
- package/payload/platform/plugins/memory/references/schema-base.md +1 -1
- package/payload/platform/plugins/workflows/PLUGIN.md +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +4 -0
- package/payload/server/chunk-DIRNBH7F.js +1603 -0
- package/payload/server/chunk-X3LVMXI5.js +10578 -0
- package/payload/server/client-pool-7Z6YRUQT.js +34 -0
- package/payload/server/maxy-edge.js +2 -2
- package/payload/server/server.js +3 -3
|
@@ -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
|
@@ -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
|
|
package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md
CHANGED
|
@@ -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
|
|
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.
|