@f5-sales-demo/xcsh 19.55.0 → 19.55.1
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/package.json +8 -8
- package/src/browser/extension-bridge.ts +9 -4
- package/src/cli/chrome-cli.ts +56 -1
- package/src/cli.ts +13 -4
- package/src/commands/chrome.ts +1 -1
- package/src/commands/manager-core.ts +49 -0
- package/src/commands/manager.ts +217 -0
- package/src/commands/native-host.ts +148 -0
- package/src/commands/worker.ts +144 -0
- package/src/extensibility/extensions/bundled/herdr-reporter.ts +86 -36
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +4 -1
- package/src/sdk.ts +22 -3
- package/src/services/native-host-install.ts +63 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.55.
|
|
4
|
+
"version": "19.55.1",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -55,13 +55,13 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
57
57
|
"@mozilla/readability": "^0.6",
|
|
58
|
-
"@f5-sales-demo/xcsh-stats": "19.55.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.55.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.55.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.55.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.55.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.55.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.55.
|
|
58
|
+
"@f5-sales-demo/xcsh-stats": "19.55.1",
|
|
59
|
+
"@f5-sales-demo/pi-agent-core": "19.55.1",
|
|
60
|
+
"@f5-sales-demo/pi-ai": "19.55.1",
|
|
61
|
+
"@f5-sales-demo/pi-natives": "19.55.1",
|
|
62
|
+
"@f5-sales-demo/pi-resource-management": "19.55.1",
|
|
63
|
+
"@f5-sales-demo/pi-tui": "19.55.1",
|
|
64
|
+
"@f5-sales-demo/pi-utils": "19.55.1",
|
|
65
65
|
"@sinclair/typebox": "^0.34",
|
|
66
66
|
"@xterm/headless": "^6.0",
|
|
67
67
|
"ajv": "^8.20",
|
|
@@ -108,7 +108,9 @@ export class BridgeServer {
|
|
|
108
108
|
/** Stable per-process session id, advertised to the extension on `hello`. */
|
|
109
109
|
#sessionId = `sess-${crypto.randomUUID()}`;
|
|
110
110
|
/** Provider of this process's tenant identity, answering the `hello` handshake. */
|
|
111
|
-
#sessionInfo:
|
|
111
|
+
#sessionInfo:
|
|
112
|
+
| (() => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean })
|
|
113
|
+
| null = null;
|
|
112
114
|
|
|
113
115
|
/** The port the WebSocket server is listening on (0 = not bound). */
|
|
114
116
|
get port(): number {
|
|
@@ -145,13 +147,15 @@ export class BridgeServer {
|
|
|
145
147
|
|
|
146
148
|
/** Set the tenant-identity provider that answers the extension's `hello`
|
|
147
149
|
* handshake with `{ tenant, env, apiUrl }` for THIS xcsh process/context. */
|
|
148
|
-
setSessionInfo(
|
|
150
|
+
setSessionInfo(
|
|
151
|
+
cb: () => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean },
|
|
152
|
+
): void {
|
|
149
153
|
this.#sessionInfo = cb;
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
/** Push a tenant change to all connected panels (e.g. after `/context activate`). */
|
|
153
157
|
broadcastTenantChanged(): void {
|
|
154
|
-
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
|
|
158
|
+
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
|
|
155
159
|
for (const c of this.#clients.values()) {
|
|
156
160
|
try {
|
|
157
161
|
c.send(JSON.stringify({ type: "tenant_changed", sessionId: this.#sessionId, ...info }));
|
|
@@ -249,7 +253,7 @@ export class BridgeServer {
|
|
|
249
253
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
250
254
|
} else if (msg.type === "hello") {
|
|
251
255
|
// Identity handshake: tell the extension which tenant this process serves.
|
|
252
|
-
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
|
|
256
|
+
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
|
|
253
257
|
const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
|
|
254
258
|
ws.send(
|
|
255
259
|
JSON.stringify({
|
|
@@ -259,6 +263,7 @@ export class BridgeServer {
|
|
|
259
263
|
tenant: info.tenant,
|
|
260
264
|
env: info.env,
|
|
261
265
|
apiUrl: info.apiUrl,
|
|
266
|
+
contextBound: info.contextBound,
|
|
262
267
|
pid: process.pid,
|
|
263
268
|
}),
|
|
264
269
|
);
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -6,12 +6,15 @@
|
|
|
6
6
|
* touching Chrome, settings, or the network.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
9
11
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
10
12
|
import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
|
|
13
|
+
import { installNativeHost } from "../services/native-host-install";
|
|
11
14
|
|
|
12
15
|
type Settings = { get(key: string): unknown };
|
|
13
16
|
|
|
14
|
-
export type ChromeAction = "status" | "relaunch" | "setup";
|
|
17
|
+
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host";
|
|
15
18
|
|
|
16
19
|
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
17
20
|
|
|
@@ -22,6 +25,49 @@ export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
|
22
25
|
*/
|
|
23
26
|
export const WEB_STORE_URL = `https://chromewebstore.google.com/detail/${EXTENSION_ID}`;
|
|
24
27
|
|
|
28
|
+
/** Find a compiled `xcsh` binary on PATH (self-contained; survives Chrome's stripped env). */
|
|
29
|
+
function defaultResolveXcshBin(): string | null {
|
|
30
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
const candidate = path.join(dir, "xcsh");
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
35
|
+
return candidate;
|
|
36
|
+
} catch {
|
|
37
|
+
/* not on this PATH entry */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the exec prefix Chrome's launcher wrapper should invoke to reach the
|
|
45
|
+
* `chrome-host` relay (the wrapper appends `chrome-host "$@"`).
|
|
46
|
+
*
|
|
47
|
+
* Compiled: `process.execPath` IS the xcsh binary → ["<xcsh>"].
|
|
48
|
+
* Dev (`bun /abs/src/cli.ts …`): `process.execPath` is bun, which fails under
|
|
49
|
+
* Chrome's stripped env for a `.ts` entry — so prefer a compiled `xcsh` on PATH
|
|
50
|
+
* when resolvable; otherwise fall back to ["<bun>", "<abs entry script>"]. The
|
|
51
|
+
* script is resolved to an ABSOLUTE path because Chrome launches the host from an
|
|
52
|
+
* arbitrary working directory.
|
|
53
|
+
*/
|
|
54
|
+
export function nativeHostLaunchCommand(
|
|
55
|
+
argv: string[] = process.argv,
|
|
56
|
+
execPath: string = process.execPath,
|
|
57
|
+
resolveXcshBin: () => string | null = defaultResolveXcshBin,
|
|
58
|
+
): string[] {
|
|
59
|
+
const base = path.basename(execPath).toLowerCase();
|
|
60
|
+
if (base.startsWith("bun")) {
|
|
61
|
+
const xcsh = resolveXcshBin();
|
|
62
|
+
if (xcsh) return [xcsh];
|
|
63
|
+
const script = argv[1];
|
|
64
|
+
if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
|
|
65
|
+
return [execPath, path.resolve(script)];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [execPath];
|
|
69
|
+
}
|
|
70
|
+
|
|
25
71
|
export function renderStatus(s: BrowserProviderStatus): string {
|
|
26
72
|
const yn = (b: boolean) => (b ? "yes" : "no");
|
|
27
73
|
return [
|
|
@@ -56,6 +102,15 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
56
102
|
`Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
|
|
57
103
|
);
|
|
58
104
|
}
|
|
105
|
+
if (action === "install-host") {
|
|
106
|
+
// Chrome ignores a manifest `args` field and can't select the `chrome-host`
|
|
107
|
+
// subcommand, so the manifest `path` points at a generated wrapper that execs
|
|
108
|
+
// the resolved xcsh with `chrome-host`. Resolve the launch prefix here (reads
|
|
109
|
+
// process.execPath/argv/PATH); installNativeHost stays a pure writer.
|
|
110
|
+
const launchCommand = nativeHostLaunchCommand();
|
|
111
|
+
const manifestPath = installNativeHost({ launchCommand, extensionIds: [EXTENSION_ID] });
|
|
112
|
+
return `Native-messaging host manifest written to:\n ${manifestPath}`;
|
|
113
|
+
}
|
|
59
114
|
// relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
|
|
60
115
|
const { mode } = await acquirePage({ settings, allowRelaunch: true });
|
|
61
116
|
return `Chrome ready (${mode}). Your real, logged-in session is now debuggable for xcsh.`;
|
package/src/cli.ts
CHANGED
|
@@ -52,16 +52,19 @@ const commands: CommandEntry[] = [
|
|
|
52
52
|
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
|
|
53
53
|
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
|
54
54
|
{ name: "chrome", load: () => import("./commands/chrome").then(m => m.default) },
|
|
55
|
+
{ name: "chrome-host", load: () => import("./commands/native-host").then(m => m.default) },
|
|
55
56
|
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
|
56
57
|
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
|
|
57
58
|
{ name: "read", load: () => import("./commands/read").then(m => m.default) },
|
|
58
59
|
{ name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
|
|
60
|
+
{ name: "manager", load: () => import("./commands/manager").then(m => m.default) },
|
|
59
61
|
{ name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
|
|
60
62
|
{ name: "setup", load: () => import("./commands/setup").then(m => m.default) },
|
|
61
63
|
{ name: "shell", load: () => import("./commands/shell").then(m => m.default) },
|
|
62
64
|
{ name: "ssh", load: () => import("./commands/ssh").then(m => m.default) },
|
|
63
65
|
{ name: "stats", load: () => import("./commands/stats").then(m => m.default) },
|
|
64
66
|
{ name: "update", load: () => import("./commands/update").then(m => m.default) },
|
|
67
|
+
{ name: "worker", load: () => import("./commands/worker").then(m => m.default) },
|
|
65
68
|
{ name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] },
|
|
66
69
|
];
|
|
67
70
|
|
|
@@ -90,11 +93,17 @@ export function runCli(argv: string[]): Promise<void> {
|
|
|
90
93
|
// Everything else that isn't a known subcommand routes to "launch".
|
|
91
94
|
const first = argv[0];
|
|
92
95
|
const runArgv =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
// Chrome launches the native-messaging host with the calling extension's
|
|
97
|
+
// origin (chrome-extension://…/) as the first arg. Route that to the
|
|
98
|
+
// `chrome-host` relay — a safety net if a manifest `path` points straight at
|
|
99
|
+
// the binary instead of the launcher wrapper.
|
|
100
|
+
first?.startsWith("chrome-extension://")
|
|
101
|
+
? ["chrome-host", ...argv]
|
|
102
|
+
: first === "--help" || first === "-h" || first === "--version" || first === "-v" || first === "help"
|
|
96
103
|
? argv
|
|
97
|
-
:
|
|
104
|
+
: isSubcommand(first)
|
|
105
|
+
? argv
|
|
106
|
+
: ["launch", ...argv];
|
|
98
107
|
return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp });
|
|
99
108
|
}
|
|
100
109
|
|
package/src/commands/chrome.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
|
|
|
5
5
|
import { type ChromeAction, runChromeCommand } from "../cli/chrome-cli";
|
|
6
6
|
import { Settings, settings } from "../config/settings";
|
|
7
7
|
|
|
8
|
-
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup"];
|
|
8
|
+
const ACTIONS: ChromeAction[] = ["status", "relaunch", "setup", "install-host"];
|
|
9
9
|
|
|
10
10
|
export default class Chrome extends Command {
|
|
11
11
|
static description = "Inspect or arrange the Chrome session xcsh drives for console automation";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Pure worker-registry + control-protocol logic for the manager. No I/O. */
|
|
2
|
+
|
|
3
|
+
export interface WorkerRec {
|
|
4
|
+
tenantKey: string; // "tenant|env"
|
|
5
|
+
port: number;
|
|
6
|
+
pid: number;
|
|
7
|
+
lastSeen: number; // epoch ms
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type Registry = Map<string, WorkerRec>;
|
|
11
|
+
|
|
12
|
+
export type ControlMsg =
|
|
13
|
+
| { type: "provision"; tenantKey: string }
|
|
14
|
+
| { type: "release"; tenantKey: string }
|
|
15
|
+
| { type: "status" };
|
|
16
|
+
|
|
17
|
+
function isTenantKey(v: unknown): v is string {
|
|
18
|
+
return typeof v === "string" && /^[^|]+\|[^|]+$/.test(v);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Validate an inbound control frame; null if malformed (fail closed). */
|
|
22
|
+
export function parseControlMsg(raw: unknown): ControlMsg | null {
|
|
23
|
+
if (!raw || typeof raw !== "object") return null;
|
|
24
|
+
const m = raw as Record<string, unknown>;
|
|
25
|
+
if (m.type === "status") return { type: "status" };
|
|
26
|
+
if ((m.type === "provision" || m.type === "release") && isTenantKey(m.tenantKey)) {
|
|
27
|
+
return { type: m.type, tenantKey: m.tenantKey };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Idempotency: only provision when there is no live worker for the key. */
|
|
33
|
+
export function needsProvision(reg: Registry, tenantKey: string): boolean {
|
|
34
|
+
return !reg.has(tenantKey);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Lowest free port in the range not already held by a worker. */
|
|
38
|
+
export function pickPort(reg: Registry, range: number[]): number | null {
|
|
39
|
+
const used = new Set([...reg.values()].map(w => w.port));
|
|
40
|
+
for (const p of range) if (!used.has(p)) return p;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Keys whose worker has been idle longer than idleMs. */
|
|
45
|
+
export function staleKeys(reg: Registry, now: number, idleMs: number): string[] {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
for (const w of reg.values()) if (now - w.lastSeen > idleMs) out.push(w.tenantKey);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manager mode — `xcsh manager`.
|
|
3
|
+
*
|
|
4
|
+
* A detached, long-lived control server that owns the fleet of per-tenant
|
|
5
|
+
* `xcsh worker` processes. It listens on a UNIX SOCKET (`~/.xcsh/manager.sock`,
|
|
6
|
+
* override `XCSH_MANAGER_SOCK`) for NDJSON control frames — one JSON object per
|
|
7
|
+
* line — validated by the pure `manager-core` protocol:
|
|
8
|
+
*
|
|
9
|
+
* {"type":"provision","tenantKey":"acme|staging"} → spawn a worker (idempotent)
|
|
10
|
+
* {"type":"release","tenantKey":"acme|staging"} → kill + forget the worker
|
|
11
|
+
* {"type":"status"} → accepted; no-op sink
|
|
12
|
+
*
|
|
13
|
+
* All registry/port/idempotency/idle policy is the pure `manager-core`; this file
|
|
14
|
+
* is the thin I/O shell (socket, spawn, kill, timer) around it. A background sweep
|
|
15
|
+
* reaps workers idle longer than the TTL. The process blocks forever.
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
21
|
+
import { portCandidates } from "../browser/extension-bridge";
|
|
22
|
+
import { needsProvision, parseControlMsg, pickPort, type Registry, staleKeys } from "./manager-core";
|
|
23
|
+
|
|
24
|
+
/** Reap a worker idle longer than this (ms). */
|
|
25
|
+
const IDLE_MS = 20 * 60_000;
|
|
26
|
+
/** How often the idle sweep runs (ms). */
|
|
27
|
+
const SWEEP_MS = 60_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether a loopback TCP port is bindable RIGHT NOW. `pickPort` only dedupes
|
|
31
|
+
* against the manager's own registry; a range port can still be held by another
|
|
32
|
+
* app, a stale worker, or a second manager. We pre-filter the range with this so
|
|
33
|
+
* a spawned worker (which binds its forced `XCSH_BRIDGE_PORT` strictly) never
|
|
34
|
+
* lands on an occupied port and dies. Bun.listen binds and throws synchronously,
|
|
35
|
+
* so this stays sync — keeping provision idempotency race-free.
|
|
36
|
+
*/
|
|
37
|
+
function isPortFree(port: number): boolean {
|
|
38
|
+
try {
|
|
39
|
+
const listener = Bun.listen({
|
|
40
|
+
hostname: "127.0.0.1",
|
|
41
|
+
port,
|
|
42
|
+
socket: { data() {}, open() {}, close() {}, error() {} },
|
|
43
|
+
});
|
|
44
|
+
listener.stop(true);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The argv (AFTER `process.execPath`) to re-run THIS binary in `mode`.
|
|
53
|
+
*
|
|
54
|
+
* Dev: launched as `bun /abs/src/cli.ts manager`, so `process.argv[1]` is the
|
|
55
|
+
* script path → `["/abs/src/cli.ts", <mode>]`, and `process.execPath` is bun.
|
|
56
|
+
*
|
|
57
|
+
* Compiled: `process.execPath` IS the xcsh binary and `process.argv[1]` is the
|
|
58
|
+
* subcommand (e.g. "manager"), not a script file → `[<mode>]`.
|
|
59
|
+
*
|
|
60
|
+
* We detect dev by the script extension so the integration tests — which run
|
|
61
|
+
* `bun src/cli.ts <mode>` — re-exec a genuinely working subcommand. Shared by
|
|
62
|
+
* the manager (worker re-exec) and the chrome-host (manager re-exec).
|
|
63
|
+
*/
|
|
64
|
+
export function reexecArgv(mode: "worker" | "manager"): string[] {
|
|
65
|
+
const script = process.argv[1];
|
|
66
|
+
if (script && (script.endsWith(".ts") || script.endsWith(".js") || script.endsWith(".mjs"))) {
|
|
67
|
+
return [script, mode];
|
|
68
|
+
}
|
|
69
|
+
return [mode];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The argv (AFTER `process.execPath`) to re-run THIS binary in `worker` mode. */
|
|
73
|
+
export function workerArgv(): string[] {
|
|
74
|
+
return reexecArgv("worker");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default class Manager extends Command {
|
|
78
|
+
static description = "Run the detached control server that spawns/reaps per-tenant workers; blocks forever";
|
|
79
|
+
|
|
80
|
+
async run(): Promise<void> {
|
|
81
|
+
const sockPath = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
|
|
82
|
+
fs.mkdirSync(dirname(sockPath), { recursive: true });
|
|
83
|
+
|
|
84
|
+
const reg: Registry = new Map();
|
|
85
|
+
const range = portCandidates();
|
|
86
|
+
|
|
87
|
+
const reap = (tenantKey: string): void => {
|
|
88
|
+
const w = reg.get(tenantKey);
|
|
89
|
+
if (!w) return;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(w.pid);
|
|
92
|
+
} catch {
|
|
93
|
+
/* already gone */
|
|
94
|
+
}
|
|
95
|
+
reg.delete(tenantKey);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const spawnWorker = (tenantKey: string): void => {
|
|
99
|
+
// Registry-dedupe (pickPort) over only the ports free at the OS level.
|
|
100
|
+
const port = pickPort(reg, range.filter(isPortFree));
|
|
101
|
+
if (port === null) {
|
|
102
|
+
console.error(`[xcsh manager] port range exhausted; cannot provision ${tenantKey}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const proc = Bun.spawn([process.execPath, ...workerArgv()], {
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
XCSH_BROWSER_PROVIDER: "extension",
|
|
109
|
+
XCSH_SESSION_TENANT: tenantKey,
|
|
110
|
+
XCSH_BRIDGE_PORT: String(port),
|
|
111
|
+
// Isolate the worker's tenant binding: an ambient XCSH_API_URL in the
|
|
112
|
+
// manager's env would make sdk.ts skip the XCSH_SESSION_TENANT branch and
|
|
113
|
+
// bind hello_ack.tenant from the env apiUrl instead. Clear both so the
|
|
114
|
+
// spawned tenant key is authoritative (undefined removes the var in Bun).
|
|
115
|
+
XCSH_API_URL: undefined,
|
|
116
|
+
XCSH_API_TOKEN: undefined,
|
|
117
|
+
},
|
|
118
|
+
stdout: "ignore",
|
|
119
|
+
stderr: "ignore",
|
|
120
|
+
});
|
|
121
|
+
reg.set(tenantKey, { tenantKey, port, pid: proc.pid, lastSeen: Date.now() });
|
|
122
|
+
// Reconcile a dead worker: when THIS process exits (crash, forced-port
|
|
123
|
+
// EADDRINUSE with no retry, etc.), drop its registry entry so the next
|
|
124
|
+
// provision respawns instead of finding a zombie. Guard on pid so an
|
|
125
|
+
// already-respawned entry for the same tenant is never clobbered.
|
|
126
|
+
proc.exited.then(() => {
|
|
127
|
+
const cur = reg.get(tenantKey);
|
|
128
|
+
if (cur && cur.pid === proc.pid) reg.delete(tenantKey);
|
|
129
|
+
});
|
|
130
|
+
console.error(`[xcsh manager] provisioned ${tenantKey} → pid ${proc.pid} on port ${port}`);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleFrame = (line: string): void => {
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed) return;
|
|
136
|
+
let raw: unknown;
|
|
137
|
+
try {
|
|
138
|
+
raw = JSON.parse(trimmed);
|
|
139
|
+
} catch {
|
|
140
|
+
return; // malformed line — ignore, keep serving
|
|
141
|
+
}
|
|
142
|
+
const msg = parseControlMsg(raw);
|
|
143
|
+
if (!msg) return; // fail closed on unknown/invalid frames
|
|
144
|
+
if (msg.type === "provision") {
|
|
145
|
+
if (needsProvision(reg, msg.tenantKey)) spawnWorker(msg.tenantKey);
|
|
146
|
+
const w = reg.get(msg.tenantKey);
|
|
147
|
+
if (w) w.lastSeen = Date.now(); // touch on every provision (keep-alive)
|
|
148
|
+
} else if (msg.type === "release") {
|
|
149
|
+
reap(msg.tenantKey);
|
|
150
|
+
}
|
|
151
|
+
// "status" is validated but currently a no-op sink.
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Per-connection NDJSON buffer: a control frame can be split across two TCP
|
|
155
|
+
// reads, so we only parse complete newline-terminated lines and retain any
|
|
156
|
+
// trailing fragment until the rest of it arrives. Keyed by socket (WeakMap
|
|
157
|
+
// so a closed connection's buffer is collected without manual bookkeeping).
|
|
158
|
+
const buffers = new WeakMap<object, string>();
|
|
159
|
+
const socketConfig = {
|
|
160
|
+
unix: sockPath,
|
|
161
|
+
socket: {
|
|
162
|
+
data(socket: object, data: Buffer): void {
|
|
163
|
+
const combined = (buffers.get(socket) ?? "") + data.toString("utf8");
|
|
164
|
+
const parts = combined.split("\n");
|
|
165
|
+
buffers.set(socket, parts.pop() ?? ""); // keep the incomplete trailing fragment
|
|
166
|
+
for (const line of parts) handleFrame(line);
|
|
167
|
+
},
|
|
168
|
+
close(socket: object): void {
|
|
169
|
+
buffers.delete(socket);
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Single-manager invariant — probe liveness BEFORE binding.
|
|
175
|
+
//
|
|
176
|
+
// Two chrome-hosts can cold-start concurrently, both find no socket, and
|
|
177
|
+
// both spawn a manager. `Bun.listen({unix})` SILENTLY unlinks and rebinds
|
|
178
|
+
// any existing socket path — it does NOT throw EADDRINUSE even when a live
|
|
179
|
+
// manager is accepting on it (verified on Bun 1.3.x, in- and cross-process).
|
|
180
|
+
// So a naive `listen` would clobber the first manager's socket, leaving it
|
|
181
|
+
// orphaned on an unlinked path (blocking forever, leaking its worker ports)
|
|
182
|
+
// while a second live manager takes over — breaking "at most one manager".
|
|
183
|
+
//
|
|
184
|
+
// Guard by CONNECTING first: if a live manager answers, this process lost
|
|
185
|
+
// the race and exits WITHOUT touching the socket file (it belongs to the
|
|
186
|
+
// live manager). If nothing answers, the path is free or a STALE file from
|
|
187
|
+
// a crashed manager — either way `Bun.listen` safely (re)binds it (its own
|
|
188
|
+
// unlink-and-rebind reclaims the stale file; no explicit rm needed).
|
|
189
|
+
let liveOwner = false;
|
|
190
|
+
try {
|
|
191
|
+
const probe = await Promise.race([
|
|
192
|
+
Bun.connect({ unix: sockPath, socket: { data() {} } }),
|
|
193
|
+
new Promise<never>((_, reject) =>
|
|
194
|
+
setTimeout(() => reject(new Error("manager liveness probe timeout")), 500),
|
|
195
|
+
),
|
|
196
|
+
]);
|
|
197
|
+
liveOwner = true;
|
|
198
|
+
probe.end();
|
|
199
|
+
} catch {
|
|
200
|
+
/* nothing accepting → free path, or a stale socket file from a crashed manager */
|
|
201
|
+
}
|
|
202
|
+
if (liveOwner) {
|
|
203
|
+
console.error(`[xcsh manager] another manager already live at ${sockPath}; exiting`);
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
Bun.listen(socketConfig);
|
|
207
|
+
console.error(`[xcsh manager] control socket listening at ${sockPath}`);
|
|
208
|
+
|
|
209
|
+
// Idle sweep: reap workers untouched for longer than the TTL.
|
|
210
|
+
setInterval(() => {
|
|
211
|
+
for (const key of staleKeys(reg, Date.now(), IDLE_MS)) reap(key);
|
|
212
|
+
}, SWEEP_MS);
|
|
213
|
+
|
|
214
|
+
// Detached, long-lived: block until the process is torn down.
|
|
215
|
+
await new Promise<never>(() => {});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chrome-host` — native-messaging relay subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Launched by Chrome as the native-messaging host. It is a thin relay between
|
|
5
|
+
* Chrome's stdio (native-messaging framing: 4-byte LE length + JSON) and the
|
|
6
|
+
* long-lived `xcsh manager` control server's UNIX socket (newline-delimited
|
|
7
|
+
* JSON). No business logic lives here.
|
|
8
|
+
*
|
|
9
|
+
* Unlike a plain relay, the host ENSURES the manager before relaying: if the
|
|
10
|
+
* control socket can't be connected (manager not yet running), it spawns a
|
|
11
|
+
* DETACHED, long-lived `xcsh manager` and retries with a short backoff. The
|
|
12
|
+
* ensure is idempotent — if the first connect succeeds no manager is spawned,
|
|
13
|
+
* so we never launch two. If the manager still can't be reached after the
|
|
14
|
+
* backoff we exit cleanly (0); the extension retries the whole bootstrap.
|
|
15
|
+
*/
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
19
|
+
import { decodeNm, encodeNm } from "../browser/native-messaging";
|
|
20
|
+
import { reexecArgv } from "./manager";
|
|
21
|
+
|
|
22
|
+
/** Manager control socket — MUST match `commands/manager.ts` exactly. */
|
|
23
|
+
const SOCKET_PATH = process.env.XCSH_MANAGER_SOCK ?? join(homedir(), ".xcsh", "manager.sock");
|
|
24
|
+
|
|
25
|
+
/** Connect-retry budget after (re)spawning the manager: ~2s total. */
|
|
26
|
+
const CONNECT_RETRIES = 20;
|
|
27
|
+
const CONNECT_BACKOFF_MS = 100;
|
|
28
|
+
|
|
29
|
+
// Diagnostic relay tracing, OFF unless ~/.xcsh/chrome-host-debug exists. Security:
|
|
30
|
+
// writes to a 0600 file in the user's home, lifecycle + byte-counts ONLY — never
|
|
31
|
+
// message content — and never to stdout (that would corrupt the NM stream).
|
|
32
|
+
function dbg(msg: string): void {
|
|
33
|
+
try {
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const home = process.env.HOME || homedir();
|
|
36
|
+
if (!fs.existsSync(join(home, ".xcsh", "chrome-host-debug"))) return;
|
|
37
|
+
const fd = fs.openSync(join(home, ".xcsh", "chrome-host.log"), "a", 0o600);
|
|
38
|
+
fs.appendFileSync(fd, `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
|
|
39
|
+
fs.closeSync(fd);
|
|
40
|
+
} catch {
|
|
41
|
+
/* logging must never break the relay */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Sock = Awaited<ReturnType<typeof Bun.connect>>;
|
|
46
|
+
|
|
47
|
+
export default class ChromeHost extends Command {
|
|
48
|
+
static description = "Native-messaging relay between Chrome and the xcsh manager socket (internal)";
|
|
49
|
+
|
|
50
|
+
async run(): Promise<void> {
|
|
51
|
+
let socketBuffer = "";
|
|
52
|
+
const socketHandlers = {
|
|
53
|
+
data(_sock: Sock, chunk: Uint8Array): void {
|
|
54
|
+
// Manager → Chrome: NDJSON lines → native-messaging frames.
|
|
55
|
+
socketBuffer += new TextDecoder().decode(chunk);
|
|
56
|
+
let newlineIndex = socketBuffer.indexOf("\n");
|
|
57
|
+
while (newlineIndex !== -1) {
|
|
58
|
+
const line = socketBuffer.slice(0, newlineIndex);
|
|
59
|
+
socketBuffer = socketBuffer.slice(newlineIndex + 1);
|
|
60
|
+
if (line.length > 0) {
|
|
61
|
+
dbg(`socket→chrome ${line.length}B`);
|
|
62
|
+
// Guard the parse/encode: a malformed manager line would otherwise
|
|
63
|
+
// throw INSIDE this Bun socket callback. Drop it (trace only) — never
|
|
64
|
+
// surface the error on stdout, which is the native-messaging stream.
|
|
65
|
+
try {
|
|
66
|
+
process.stdout.write(encodeNm(JSON.parse(line)));
|
|
67
|
+
} catch {
|
|
68
|
+
dbg("socket→chrome drop: malformed line");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
newlineIndex = socketBuffer.indexOf("\n");
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
close(): void {
|
|
75
|
+
dbg("manager socket closed → exit");
|
|
76
|
+
process.exit(0);
|
|
77
|
+
},
|
|
78
|
+
error(): void {
|
|
79
|
+
dbg("manager socket error → exit");
|
|
80
|
+
process.exit(0);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
dbg(`start sock=${SOCKET_PATH} argc=${process.argv.length}`);
|
|
85
|
+
const socket = await this.ensureManager(socketHandlers);
|
|
86
|
+
if (!socket) {
|
|
87
|
+
// Manager unreachable after ensure — exit cleanly; the extension retries.
|
|
88
|
+
dbg("ensureManager: unreachable → exit");
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Chrome stdin → manager: native-messaging frames → NDJSON lines.
|
|
93
|
+
dbg("reading chrome stdin");
|
|
94
|
+
let stdinBuffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
95
|
+
for await (const chunk of process.stdin) {
|
|
96
|
+
const bytes = new Uint8Array(chunk);
|
|
97
|
+
const next = new Uint8Array(stdinBuffer.length + bytes.length);
|
|
98
|
+
next.set(stdinBuffer, 0);
|
|
99
|
+
next.set(bytes, stdinBuffer.length);
|
|
100
|
+
const { messages, rest } = decodeNm(next);
|
|
101
|
+
stdinBuffer = rest;
|
|
102
|
+
for (const msg of messages) {
|
|
103
|
+
dbg(`chrome→socket ${JSON.stringify(msg).length}B`);
|
|
104
|
+
socket.write(`${JSON.stringify(msg)}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Chrome closed stdin — tear down and exit.
|
|
109
|
+
dbg("chrome stdin EOF → exit");
|
|
110
|
+
socket.end();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Connect to the manager, spawning a detached one if the first connect fails.
|
|
116
|
+
* Idempotent: a successful first connect spawns nothing (never two managers).
|
|
117
|
+
* Returns the connected socket, or undefined if unreachable after backoff.
|
|
118
|
+
*/
|
|
119
|
+
private async ensureManager(socket: Parameters<typeof Bun.connect>[0]["socket"]): Promise<Sock | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
dbg("connect: first attempt");
|
|
122
|
+
return await Bun.connect({ unix: SOCKET_PATH, socket });
|
|
123
|
+
} catch {
|
|
124
|
+
dbg("connect: failed → spawn detached manager");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Spawn the manager DETACHED and long-lived. It is NOT awaited/retained so
|
|
128
|
+
// it outlives this host process; on macOS killing the host does not kill it.
|
|
129
|
+
Bun.spawn([process.execPath, ...reexecArgv("manager")], {
|
|
130
|
+
stdin: "ignore",
|
|
131
|
+
stdout: "ignore",
|
|
132
|
+
stderr: "ignore",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Retry-connect with a short backoff while the manager binds its socket.
|
|
136
|
+
for (let i = 0; i < CONNECT_RETRIES; i++) {
|
|
137
|
+
await Bun.sleep(CONNECT_BACKOFF_MS);
|
|
138
|
+
try {
|
|
139
|
+
const s = await Bun.connect({ unix: SOCKET_PATH, socket });
|
|
140
|
+
dbg(`connect: succeeded after ${i + 1} retries`);
|
|
141
|
+
return s;
|
|
142
|
+
} catch {
|
|
143
|
+
/* manager not bound yet — keep retrying */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless worker mode — `xcsh worker`.
|
|
3
|
+
*
|
|
4
|
+
* A non-interactive process that starts the Chrome-extension bridge, creates ONE
|
|
5
|
+
* agent session bound to this worker's tenant (`XCSH_SESSION_TENANT`, matched to a
|
|
6
|
+
* context by the session-context bootstrap in `createAgentSession`), attaches the
|
|
7
|
+
* chat handler, and blocks until SIGTERM/SIGINT. It mirrors the extension-bridge
|
|
8
|
+
* startup path in `main.ts` (bridge → setSessionInfo → browser-only tool scoping →
|
|
9
|
+
* createAgentSession → ChatHandler.attach) MINUS the TUI.
|
|
10
|
+
*
|
|
11
|
+
* Unlike the interactive path — whose `hello_ack` tenant is derived purely from the
|
|
12
|
+
* active context's apiUrl (null when contextless) — the worker also falls back to
|
|
13
|
+
* `XCSH_SESSION_TENANT` so it advertises its assigned tenant even before a context
|
|
14
|
+
* is bound. This lets the extension panel lock onto the right tenant immediately.
|
|
15
|
+
*/
|
|
16
|
+
import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
17
|
+
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
18
|
+
import { ChatHandler } from "../browser/chat-handler";
|
|
19
|
+
import { startBridgeServer } from "../browser/extension-bridge";
|
|
20
|
+
import { setSharedBridgeServer } from "../browser/provider";
|
|
21
|
+
import { initializeWithSettings } from "../discovery";
|
|
22
|
+
import { createAgentSession } from "../sdk";
|
|
23
|
+
import { ContextService } from "../services/xcsh-context";
|
|
24
|
+
import { sessionKeyFromUrl } from "../services/xcsh-env";
|
|
25
|
+
|
|
26
|
+
/** Tenant identity for the `hello` handshake. The active context wins; when the
|
|
27
|
+
* worker is contextless we parse `XCSH_SESSION_TENANT` (`tenant|env`) so the panel
|
|
28
|
+
* still learns which tenant this process serves (apiUrl stays null). Must be sync —
|
|
29
|
+
* the bridge invokes it synchronously while answering `hello`. */
|
|
30
|
+
export function sessionInfoForWorker(): {
|
|
31
|
+
tenant: string | null;
|
|
32
|
+
env: string | null;
|
|
33
|
+
apiUrl: string | null;
|
|
34
|
+
contextBound: boolean;
|
|
35
|
+
} {
|
|
36
|
+
let apiUrl: string | null = null;
|
|
37
|
+
let contextBound = false;
|
|
38
|
+
try {
|
|
39
|
+
apiUrl = ContextService.instance.activeApiUrl;
|
|
40
|
+
// A worker is "context-bound" when it has an active stored context (not just an env-derived apiUrl).
|
|
41
|
+
contextBound = ContextService.instance.getStatus().activeContextName != null;
|
|
42
|
+
} catch {
|
|
43
|
+
/* ContextService not initialized — fall through to env; contextBound stays false. */
|
|
44
|
+
}
|
|
45
|
+
apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
|
|
46
|
+
if (apiUrl) {
|
|
47
|
+
const key = sessionKeyFromUrl(apiUrl);
|
|
48
|
+
return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
|
|
49
|
+
}
|
|
50
|
+
// Contextless: the worker's assigned tenant is carried in XCSH_SESSION_TENANT.
|
|
51
|
+
const raw = process.env.XCSH_SESSION_TENANT;
|
|
52
|
+
if (raw) {
|
|
53
|
+
const [tenant, env] = raw.split("|");
|
|
54
|
+
return { tenant: tenant || null, env: env || null, apiUrl: null, contextBound };
|
|
55
|
+
}
|
|
56
|
+
return { tenant: null, env: null, apiUrl: null, contextBound };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Browser-automation tool set — identical scoping to `main.ts`'s extension path.
|
|
60
|
+
* With scoped tools the ONLY way to create a resource is the form-driven workflow
|
|
61
|
+
* runner, which is exactly what the human watching the browser wants. */
|
|
62
|
+
const BROWSER_TOOL_NAMES = [
|
|
63
|
+
"catalog_workflow_runner",
|
|
64
|
+
"navigate",
|
|
65
|
+
"click",
|
|
66
|
+
"click_element",
|
|
67
|
+
"fill",
|
|
68
|
+
"type_text",
|
|
69
|
+
"screenshot",
|
|
70
|
+
"login",
|
|
71
|
+
"read_ax",
|
|
72
|
+
"get_page_context",
|
|
73
|
+
"query_dom",
|
|
74
|
+
"find",
|
|
75
|
+
"wait_for",
|
|
76
|
+
"key_press",
|
|
77
|
+
"select_option",
|
|
78
|
+
"label_select",
|
|
79
|
+
"scroll_to",
|
|
80
|
+
"annotate",
|
|
81
|
+
"set_explain_mode",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export default class Worker extends Command {
|
|
85
|
+
static description = "Run a headless extension-bridge worker (no TUI); blocks until SIGTERM";
|
|
86
|
+
|
|
87
|
+
async run(): Promise<void> {
|
|
88
|
+
process.env.XCSH_BROWSER_PROVIDER = "extension";
|
|
89
|
+
|
|
90
|
+
const cwd = getProjectDir();
|
|
91
|
+
const { Settings, settings } = await import("../config/settings");
|
|
92
|
+
await Settings.init({ cwd });
|
|
93
|
+
|
|
94
|
+
// Init the ContextService singleton so the session-context bootstrap (Task 3)
|
|
95
|
+
// can match XCSH_SESSION_TENANT to a stored context, and so sessionInfoForWorker
|
|
96
|
+
// can read the active apiUrl once bound.
|
|
97
|
+
try {
|
|
98
|
+
ContextService.init(getXCSHConfigDir());
|
|
99
|
+
} catch {
|
|
100
|
+
/* already initialized / unavailable — continue. */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Provider persistence for model discovery (parity with main.ts).
|
|
104
|
+
initializeWithSettings(settings);
|
|
105
|
+
|
|
106
|
+
// Quiet startup: skip the welcome screen + blocking plugin "Fix now?" prompts.
|
|
107
|
+
settings.override("startup.quiet", true);
|
|
108
|
+
|
|
109
|
+
// INSTANT-ON: start the bridge before the heavy session init so the extension
|
|
110
|
+
// can connect immediately. Honors XCSH_BRIDGE_PORT (forced) or auto-selects.
|
|
111
|
+
const bridge = await startBridgeServer();
|
|
112
|
+
console.error(`[xcsh worker] extension bridge listening on ws://127.0.0.1:${bridge.port}`);
|
|
113
|
+
setSharedBridgeServer(bridge);
|
|
114
|
+
bridge.setSessionInfo(sessionInfoForWorker);
|
|
115
|
+
ContextService.onContextChange(() => bridge.broadcastTenantChanged());
|
|
116
|
+
|
|
117
|
+
const { session } = await createAgentSession({
|
|
118
|
+
cwd,
|
|
119
|
+
hasUI: false,
|
|
120
|
+
toolNames: BROWSER_TOOL_NAMES,
|
|
121
|
+
// Headless worker: no MCP discovery, no LSP warmup, no extension discovery —
|
|
122
|
+
// keep startup lean and free of network calls / blocking prompts.
|
|
123
|
+
enableMCP: false,
|
|
124
|
+
enableLsp: false,
|
|
125
|
+
disableExtensionDiscovery: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const chatHandler = new ChatHandler(bridge, session);
|
|
129
|
+
chatHandler.attach();
|
|
130
|
+
|
|
131
|
+
let shuttingDown = false;
|
|
132
|
+
const shutdown = () => {
|
|
133
|
+
if (shuttingDown) return;
|
|
134
|
+
shuttingDown = true;
|
|
135
|
+
chatHandler.dispose();
|
|
136
|
+
void bridge.close().finally(() => process.exit(0));
|
|
137
|
+
};
|
|
138
|
+
process.on("SIGTERM", shutdown);
|
|
139
|
+
process.on("SIGINT", shutdown);
|
|
140
|
+
|
|
141
|
+
// Block until a signal tears us down.
|
|
142
|
+
await new Promise<never>(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -1,23 +1,77 @@
|
|
|
1
|
+
import { connect } from "node:net";
|
|
1
2
|
import type { ExtensionAPI } from "@f5-sales-demo/xcsh";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* herdr integration (bundled, default-on).
|
|
5
6
|
*
|
|
6
|
-
* Reports xcsh's live agent state to the herdr terminal multiplexer
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Reports xcsh's live agent state to the herdr terminal multiplexer so an xcsh
|
|
8
|
+
* pane shows up as a first-class "xcsh" assistant with an idle / working /
|
|
9
|
+
* blocked indicator.
|
|
10
|
+
*
|
|
11
|
+
* Transport: herdr injects `HERDR_SOCKET_PATH` into every pane, so state is
|
|
12
|
+
* reported by writing a newline-delimited JSON-RPC request straight to that unix
|
|
13
|
+
* socket. This is PATH-independent — unlike shelling out to the `herdr` CLI,
|
|
14
|
+
* which silently no-ops when herdr runs as a launchd/`brew services` server and
|
|
15
|
+
* spawns panes without `/opt/homebrew/bin` on PATH. If `HERDR_SOCKET_PATH` is
|
|
16
|
+
* somehow unset, we fall back to the `herdr` CLI.
|
|
9
17
|
*
|
|
10
18
|
* xcsh is a fork of pi; a user may have both installed, so this reporter always
|
|
11
19
|
* identifies itself as "xcsh" (never "pi") and claims pane authority via
|
|
12
|
-
*
|
|
13
|
-
* pane.
|
|
20
|
+
* `source: "xcsh"` so herdr's passive pi-detection heuristics cannot mislabel
|
|
21
|
+
* the pane.
|
|
14
22
|
*
|
|
15
23
|
* The extension is completely inert unless it is running inside a herdr pane
|
|
16
|
-
* (detected via
|
|
17
|
-
*
|
|
24
|
+
* (detected via `HERDR_PANE_ID`), so it has zero effect for users who do not run
|
|
25
|
+
* xcsh under herdr.
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
const HERDR_AGENT_LABEL = "xcsh";
|
|
29
|
+
const REPORT_METHOD = "pane.report_agent";
|
|
30
|
+
const RELEASE_METHOD = "pane.release_agent";
|
|
31
|
+
const SOCKET_TIMEOUT_MS = 2000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write one newline-delimited JSON-RPC request to herdr's unix socket and close.
|
|
35
|
+
* Fire-and-forget: the response is ignored and every failure path is reported
|
|
36
|
+
* via `onError` without throwing, so a dead socket never disturbs the agent.
|
|
37
|
+
*/
|
|
38
|
+
function sendToHerdrSocket(
|
|
39
|
+
socketPath: string,
|
|
40
|
+
method: string,
|
|
41
|
+
params: Record<string, unknown>,
|
|
42
|
+
onError: (err: unknown) => void,
|
|
43
|
+
): void {
|
|
44
|
+
const conn = connect({ path: socketPath });
|
|
45
|
+
// Never let a report keep xcsh's event loop alive.
|
|
46
|
+
conn.unref();
|
|
47
|
+
conn.once("error", err => {
|
|
48
|
+
onError(err);
|
|
49
|
+
conn.destroy();
|
|
50
|
+
});
|
|
51
|
+
conn.once("connect", () => {
|
|
52
|
+
conn.end(`${JSON.stringify({ id: "xcsh:herdr-reporter", method, params })}\n`);
|
|
53
|
+
});
|
|
54
|
+
conn.setTimeout(SOCKET_TIMEOUT_MS, () => conn.destroy());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Translate a JSON-RPC report/release into `herdr` CLI arguments (fallback). */
|
|
58
|
+
function toCliArgs(method: string, params: Record<string, unknown>): string[] {
|
|
59
|
+
const subcommand = method === REPORT_METHOD ? "report-agent" : "release-agent";
|
|
60
|
+
const args = [
|
|
61
|
+
"pane",
|
|
62
|
+
subcommand,
|
|
63
|
+
String(params.pane_id),
|
|
64
|
+
"--source",
|
|
65
|
+
String(params.source),
|
|
66
|
+
"--agent",
|
|
67
|
+
String(params.agent),
|
|
68
|
+
];
|
|
69
|
+
if (params.state !== undefined) {
|
|
70
|
+
args.push("--state", String(params.state));
|
|
71
|
+
}
|
|
72
|
+
args.push("--seq", String(params.seq));
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
21
75
|
|
|
22
76
|
export default function herdrReporter(pi: ExtensionAPI): void {
|
|
23
77
|
const paneId = process.env.HERDR_PANE_ID;
|
|
@@ -33,29 +87,30 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
33
87
|
|
|
34
88
|
pi.setLabel(HERDR_AGENT_LABEL);
|
|
35
89
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
pi.logger.debug("herdr report failed", {
|
|
40
|
-
error: err instanceof Error ? err.message : String(err),
|
|
41
|
-
});
|
|
90
|
+
const onError = (err: unknown): void => {
|
|
91
|
+
pi.logger.debug("herdr report failed", {
|
|
92
|
+
error: err instanceof Error ? err.message : String(err),
|
|
42
93
|
});
|
|
43
94
|
};
|
|
44
95
|
|
|
96
|
+
const send = (method: string, params: Record<string, unknown>): void => {
|
|
97
|
+
const socketPath = process.env.HERDR_SOCKET_PATH;
|
|
98
|
+
if (socketPath) {
|
|
99
|
+
sendToHerdrSocket(socketPath, method, params, onError);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Fallback for the rare case herdr did not inject a socket path.
|
|
103
|
+
pi.exec("herdr", toCliArgs(method, params)).catch(onError);
|
|
104
|
+
};
|
|
105
|
+
|
|
45
106
|
const report = (state: "idle" | "working" | "blocked"): void => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"--source",
|
|
51
|
-
HERDR_AGENT_LABEL,
|
|
52
|
-
"--agent",
|
|
53
|
-
HERDR_AGENT_LABEL,
|
|
54
|
-
"--state",
|
|
107
|
+
send(REPORT_METHOD, {
|
|
108
|
+
pane_id: paneId,
|
|
109
|
+
source: HERDR_AGENT_LABEL,
|
|
110
|
+
agent: HERDR_AGENT_LABEL,
|
|
55
111
|
state,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
]);
|
|
112
|
+
seq: seq++,
|
|
113
|
+
});
|
|
59
114
|
};
|
|
60
115
|
|
|
61
116
|
// Announce presence as soon as the session is initialized.
|
|
@@ -87,16 +142,11 @@ export default function herdrReporter(pi: ExtensionAPI): void {
|
|
|
87
142
|
|
|
88
143
|
// Relinquish pane authority so herdr stops showing xcsh once we exit.
|
|
89
144
|
pi.on("session_shutdown", () => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"--agent",
|
|
97
|
-
HERDR_AGENT_LABEL,
|
|
98
|
-
"--seq",
|
|
99
|
-
String(seq++),
|
|
100
|
-
]);
|
|
145
|
+
send(RELEASE_METHOD, {
|
|
146
|
+
pane_id: paneId,
|
|
147
|
+
source: HERDR_AGENT_LABEL,
|
|
148
|
+
agent: HERDR_AGENT_LABEL,
|
|
149
|
+
seq: seq++,
|
|
150
|
+
});
|
|
101
151
|
});
|
|
102
152
|
}
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.55.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.55.1",
|
|
21
|
+
"commit": "48c9f78cdcabe71d95cc97b067c64119f03fb22d",
|
|
22
|
+
"shortCommit": "48c9f78",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.55.
|
|
25
|
-
"commitDate": "2026-07-
|
|
26
|
-
"buildDate": "2026-07-
|
|
24
|
+
"tag": "v19.55.1",
|
|
25
|
+
"commitDate": "2026-07-03T01:03:31Z",
|
|
26
|
+
"buildDate": "2026-07-03T01:25:43.578Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5-sales-demo/xcsh",
|
|
30
30
|
"repoSlug": "f5-sales-demo/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/48c9f78cdcabe71d95cc97b067c64119f03fb22d",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.55.1"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -826,15 +826,18 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
826
826
|
const { sessionKeyFromUrl } = await import("./services/xcsh-env");
|
|
827
827
|
const readInfo = () => {
|
|
828
828
|
let apiUrl: string | null = null;
|
|
829
|
+
let contextBound = false;
|
|
829
830
|
try {
|
|
830
831
|
apiUrl = ContextService.instance.activeApiUrl;
|
|
832
|
+
// Context-bound when a stored context is active (not just an env-derived apiUrl).
|
|
833
|
+
contextBound = ContextService.instance.getStatus().activeContextName != null;
|
|
831
834
|
} catch {
|
|
832
835
|
/* ContextService not initialized */
|
|
833
836
|
}
|
|
834
837
|
// Fall back to the env override when no context is active (env-only mode).
|
|
835
838
|
apiUrl = apiUrl ?? process.env.XCSH_API_URL ?? null;
|
|
836
839
|
const key = apiUrl ? sessionKeyFromUrl(apiUrl) : null;
|
|
837
|
-
return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl };
|
|
840
|
+
return { tenant: key?.tenant ?? null, env: key?.env ?? null, apiUrl, contextBound };
|
|
838
841
|
};
|
|
839
842
|
bridgeServer.setSessionInfo(readInfo);
|
|
840
843
|
ContextService.onContextChange(() => bridgeServer?.broadcastTenantChanged());
|
package/src/sdk.ts
CHANGED
|
@@ -783,9 +783,28 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
783
783
|
const svc = ContextService.instance; // inited in main.ts (throws for SDK/tests → caught)
|
|
784
784
|
if (!process.env.XCSH_API_URL) {
|
|
785
785
|
const bound = existingSession.activeContextName; // resumed binding, if any
|
|
786
|
-
const
|
|
787
|
-
const
|
|
788
|
-
const
|
|
786
|
+
const contexts = await svc.listContexts();
|
|
787
|
+
const available = contexts.map(c => c.name);
|
|
788
|
+
const tenantKey = process.env.XCSH_SESSION_TENANT;
|
|
789
|
+
let autoBind: ReturnType<typeof resolveAutoBind>;
|
|
790
|
+
if (tenantKey) {
|
|
791
|
+
// Extension worker: match a context to this worker's tenant.
|
|
792
|
+
const { sessionKeyFromUrl } = await import("./services/xcsh-env");
|
|
793
|
+
const contextTenantKeys: Record<string, string> = {};
|
|
794
|
+
for (const c of contexts) {
|
|
795
|
+
const key = c.apiUrl ? sessionKeyFromUrl(c.apiUrl) : null;
|
|
796
|
+
if (key) contextTenantKeys[c.name] = `${key.tenant}|${key.env}`;
|
|
797
|
+
}
|
|
798
|
+
autoBind = resolveAutoBind({
|
|
799
|
+
kind: "extension",
|
|
800
|
+
availableContexts: available,
|
|
801
|
+
tenantKey,
|
|
802
|
+
contextTenantKeys,
|
|
803
|
+
});
|
|
804
|
+
} else {
|
|
805
|
+
const folderContext = await svc.resolveFolderContextName(cwd);
|
|
806
|
+
autoBind = resolveAutoBind({ kind: "cli", availableContexts: available, folderContext });
|
|
807
|
+
}
|
|
789
808
|
const choice = chooseSessionContext(bound, autoBind);
|
|
790
809
|
if ("activate" in choice) {
|
|
791
810
|
try {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Chrome native-messaging host name (matches the pre-WS-transition host). */
|
|
6
|
+
export const NATIVE_HOST_NAME = "com.xcsh.xcsh.chrome_host";
|
|
7
|
+
|
|
8
|
+
function nativeHostDir(home: string, platform: NodeJS.Platform): string {
|
|
9
|
+
if (platform === "darwin")
|
|
10
|
+
return path.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
|
|
11
|
+
if (platform === "linux") return path.join(home, ".config", "google-chrome", "NativeMessagingHosts");
|
|
12
|
+
throw new Error(`native-host install unsupported on platform '${platform}' (macOS/Linux only)`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Single-quote a token for a POSIX `sh` command line (safe for spaces/specials). */
|
|
16
|
+
function shQuote(token: string): string {
|
|
17
|
+
return `'${token.replace(/'/g, `'\\''`)}'`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Idempotently write the user-level NM host manifest AND its launcher wrapper.
|
|
22
|
+
* Returns the manifest path.
|
|
23
|
+
*
|
|
24
|
+
* Chrome native-messaging manifests support ONLY {name, description, path, type,
|
|
25
|
+
* allowed_origins} — Chrome does NOT honor an `args` field, and when it launches
|
|
26
|
+
* the host it passes the calling extension's origin as argv[1] to `path`. So the
|
|
27
|
+
* manifest CANNOT point straight at the xcsh binary (that would run the default
|
|
28
|
+
* `launch`/TUI, never `chrome-host`). Instead `path` points at a generated
|
|
29
|
+
* executable wrapper that execs the real xcsh with the `chrome-host` subcommand
|
|
30
|
+
* and forwards Chrome's args ("$@"). `launchCommand` is the resolved exec prefix
|
|
31
|
+
* (e.g. ["/usr/local/bin/xcsh"] compiled, or ["/path/bun", "/abs/src/cli.ts"] in
|
|
32
|
+
* dev) — resolved by the CLI layer so this stays a pure writer.
|
|
33
|
+
*/
|
|
34
|
+
export function installNativeHost(opts: {
|
|
35
|
+
launchCommand: string[];
|
|
36
|
+
extensionIds: string[];
|
|
37
|
+
home?: string;
|
|
38
|
+
platform?: NodeJS.Platform;
|
|
39
|
+
}): string {
|
|
40
|
+
if (opts.launchCommand.length === 0) throw new Error("installNativeHost: launchCommand must not be empty");
|
|
41
|
+
const home = opts.home ?? os.homedir();
|
|
42
|
+
const platform = opts.platform ?? process.platform;
|
|
43
|
+
const dir = nativeHostDir(home, platform);
|
|
44
|
+
const manifestPath = path.join(dir, `${NATIVE_HOST_NAME}.json`);
|
|
45
|
+
const wrapperPath = path.join(dir, `${NATIVE_HOST_NAME}.sh`);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
|
|
48
|
+
// Executable launcher: exec the real xcsh with `chrome-host`, forwarding
|
|
49
|
+
// Chrome's origin arg via "$@". 0o755 so Chrome can execute it directly.
|
|
50
|
+
const wrapper = `#!/bin/sh\nexec ${opts.launchCommand.map(shQuote).join(" ")} chrome-host "$@"\n`;
|
|
51
|
+
fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 });
|
|
52
|
+
fs.chmodSync(wrapperPath, 0o755); // writeFileSync mode is subject to umask; force it.
|
|
53
|
+
|
|
54
|
+
const manifest = {
|
|
55
|
+
name: NATIVE_HOST_NAME,
|
|
56
|
+
description: "xcsh Chrome native-messaging host (auto-provisioning bootstrap)",
|
|
57
|
+
path: wrapperPath,
|
|
58
|
+
type: "stdio",
|
|
59
|
+
allowed_origins: opts.extensionIds.map(id => `chrome-extension://${id}/`),
|
|
60
|
+
};
|
|
61
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
62
|
+
return manifestPath;
|
|
63
|
+
}
|