@f5-sales-demo/xcsh 19.51.5 → 19.51.6
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.51.
|
|
4
|
+
"version": "19.51.6",
|
|
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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"@f5-sales-demo/pi-natives": "19.51.4",
|
|
61
61
|
"@f5-sales-demo/pi-resource-management": "19.51.4",
|
|
62
62
|
"@f5-sales-demo/pi-tui": "19.51.4",
|
|
63
|
-
"@f5-sales-demo/pi-utils": "19.51.
|
|
63
|
+
"@f5-sales-demo/pi-utils": "19.51.6",
|
|
64
64
|
"@sinclair/typebox": "^0.34",
|
|
65
65
|
"@xterm/headless": "^6.0",
|
|
66
66
|
"ajv": "^8.20",
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import
|
|
3
|
-
import * as os from "node:os";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import type { Socket, UnixSocketListener } from "bun";
|
|
2
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
6
3
|
|
|
7
4
|
export interface ToolResult {
|
|
8
5
|
content: unknown;
|
|
@@ -59,21 +56,36 @@ export class PendingRequests {
|
|
|
59
56
|
|
|
60
57
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
61
58
|
|
|
62
|
-
|
|
59
|
+
/** Default loopback port for the extension WebSocket bridge. */
|
|
60
|
+
export const DEFAULT_PORT = 19222;
|
|
61
|
+
|
|
62
|
+
/** Resolve the bridge port from an explicit value, then `XCSH_BRIDGE_PORT`, then the default. */
|
|
63
|
+
export function resolvePort(port?: number): number {
|
|
64
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0) return port;
|
|
65
|
+
const env = Number(process.env.XCSH_BRIDGE_PORT);
|
|
66
|
+
if (Number.isFinite(env) && env > 0) return env;
|
|
67
|
+
return DEFAULT_PORT;
|
|
68
|
+
}
|
|
63
69
|
|
|
64
70
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
71
|
+
* Loopback WebSocket server bridging xcsh to the Chrome extension. Speaks JSON
|
|
72
|
+
* over WS frames: requests `{type:"tool_request",...}`, replies
|
|
73
|
+
* `{type:"tool_result",...}`, plus `{type:"ping"|"pong"}`. Tracks a single
|
|
74
|
+
* connected client (a new connection replaces the prior one) and correlates
|
|
75
|
+
* replies via {@link PendingRequests}.
|
|
69
76
|
*/
|
|
70
77
|
export class BridgeServer {
|
|
71
78
|
#pending = new PendingRequests();
|
|
72
|
-
#
|
|
73
|
-
#client:
|
|
79
|
+
#server: Server<undefined> | null = null;
|
|
80
|
+
#client: ServerWebSocket<undefined> | null = null;
|
|
74
81
|
#onConnected: Array<() => void> = [];
|
|
75
82
|
#onDisconnected: Array<() => void> = [];
|
|
76
83
|
|
|
84
|
+
/** The port the WebSocket server is listening on (0 = not bound). */
|
|
85
|
+
get port(): number {
|
|
86
|
+
return this.#server?.port ?? 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
get connected(): boolean {
|
|
78
90
|
return this.#client !== null;
|
|
79
91
|
}
|
|
@@ -86,44 +98,47 @@ export class BridgeServer {
|
|
|
86
98
|
this.#onDisconnected.push(cb);
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
/** Bind the server to a
|
|
90
|
-
listen(
|
|
91
|
-
this.#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
/** Bind the WebSocket server to a loopback port. Called by {@link startBridgeServer}. */
|
|
102
|
+
listen(port: number, opts?: { skipOriginCheck?: boolean }): void {
|
|
103
|
+
this.#server = Bun.serve({
|
|
104
|
+
port,
|
|
105
|
+
hostname: "127.0.0.1",
|
|
106
|
+
fetch: (req, server) => {
|
|
107
|
+
// Validate the Origin header: only the xcsh Chrome extension may connect.
|
|
108
|
+
// This restores the access-control guarantee that the Unix socket's 0o600
|
|
109
|
+
// permissions previously provided.
|
|
110
|
+
if (!opts?.skipOriginCheck) {
|
|
111
|
+
const origin = req.headers.get("origin") ?? "";
|
|
112
|
+
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
113
|
+
if (origin !== `chrome-extension://${EXTENSION_ID}`) {
|
|
114
|
+
return new Response("Forbidden", { status: 403 });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (server.upgrade(req)) return undefined;
|
|
118
|
+
return new Response("xcsh bridge: WebSocket only", { status: 426 });
|
|
119
|
+
},
|
|
120
|
+
websocket: {
|
|
121
|
+
open: ws => {
|
|
122
|
+
// One client at a time: close any prior connection on a new connect.
|
|
123
|
+
if (this.#client && this.#client !== ws) this.#client.close();
|
|
124
|
+
this.#client = ws;
|
|
97
125
|
for (const cb of this.#onConnected) cb();
|
|
98
126
|
},
|
|
99
|
-
|
|
100
|
-
this.#
|
|
101
|
-
},
|
|
102
|
-
close: socket => {
|
|
103
|
-
this.#onClose(socket);
|
|
127
|
+
message: (ws, message) => {
|
|
128
|
+
this.#handleMessage(ws, message);
|
|
104
129
|
},
|
|
105
|
-
|
|
106
|
-
this.#onClose(
|
|
130
|
+
close: ws => {
|
|
131
|
+
this.#onClose(ws);
|
|
107
132
|
},
|
|
108
133
|
},
|
|
109
134
|
});
|
|
110
135
|
}
|
|
111
136
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
let idx = socket.data.buf.indexOf("\n");
|
|
115
|
-
while (idx !== -1) {
|
|
116
|
-
const line = socket.data.buf.slice(0, idx);
|
|
117
|
-
socket.data.buf = socket.data.buf.slice(idx + 1);
|
|
118
|
-
if (line.length > 0) this.#handleLine(socket, line);
|
|
119
|
-
idx = socket.data.buf.indexOf("\n");
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
#handleLine(socket: Socket<BridgeData>, line: string): void {
|
|
137
|
+
#handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
|
|
138
|
+
const text = typeof message === "string" ? message : message.toString("utf8");
|
|
124
139
|
let msg: { type?: string; id?: string; content?: unknown; is_error?: boolean };
|
|
125
140
|
try {
|
|
126
|
-
msg = JSON.parse(
|
|
141
|
+
msg = JSON.parse(text);
|
|
127
142
|
} catch {
|
|
128
143
|
return;
|
|
129
144
|
}
|
|
@@ -133,50 +148,42 @@ export class BridgeServer {
|
|
|
133
148
|
is_error: msg.is_error === true,
|
|
134
149
|
});
|
|
135
150
|
} else if (msg.type === "ping") {
|
|
136
|
-
|
|
151
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
137
152
|
}
|
|
138
153
|
}
|
|
139
154
|
|
|
140
|
-
#onClose(
|
|
141
|
-
if (this.#client !==
|
|
155
|
+
#onClose(ws: ServerWebSocket<undefined>): void {
|
|
156
|
+
if (this.#client !== ws) return;
|
|
142
157
|
this.#client = null;
|
|
143
158
|
this.#pending.rejectAll(new Error("bridge client disconnected"));
|
|
144
159
|
for (const cb of this.#onDisconnected) cb();
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
#write(socket: Socket<BridgeData>, msg: unknown): void {
|
|
148
|
-
socket.write(`${JSON.stringify(msg)}\n`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
162
|
/** Send a `tool_request` to the connected client and await its `tool_result`. */
|
|
152
163
|
request(tool: string, params: unknown, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<ToolResult> {
|
|
153
164
|
const client = this.#client;
|
|
154
165
|
if (!client) return Promise.reject(new Error("bridge: no client connected"));
|
|
155
166
|
const { id, promise } = this.#pending.create(timeoutMs);
|
|
156
|
-
|
|
167
|
+
client.send(JSON.stringify({ type: "tool_request", id, tool, params }));
|
|
157
168
|
return promise;
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
async close(): Promise<void> {
|
|
161
172
|
this.#pending.rejectAll(new Error("bridge server closed"));
|
|
162
|
-
this.#client?.
|
|
173
|
+
this.#client?.close();
|
|
163
174
|
this.#client = null;
|
|
164
|
-
this.#
|
|
165
|
-
this.#
|
|
175
|
+
this.#server?.stop(true);
|
|
176
|
+
this.#server = null;
|
|
166
177
|
}
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
181
|
+
* Start the {@link BridgeServer} on the resolved loopback port (explicit arg,
|
|
182
|
+
* then `XCSH_BRIDGE_PORT`, then {@link DEFAULT_PORT}). The WebSocket transport
|
|
183
|
+
* needs no filesystem setup — Chrome connects directly to `ws://127.0.0.1:<port>`.
|
|
173
184
|
*/
|
|
174
|
-
export async function startBridgeServer(
|
|
175
|
-
const resolved = socketPath ?? path.join(os.homedir(), ".xcsh", "chrome-bridge.sock");
|
|
176
|
-
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
177
|
-
fs.rmSync(resolved, { force: true });
|
|
185
|
+
export async function startBridgeServer(port?: number, opts?: { skipOriginCheck?: boolean }): Promise<BridgeServer> {
|
|
178
186
|
const server = new BridgeServer();
|
|
179
|
-
server.listen(
|
|
180
|
-
fs.chmodSync(resolved, 0o600);
|
|
187
|
+
server.listen(resolvePort(port), opts);
|
|
181
188
|
return server;
|
|
182
189
|
}
|
package/src/browser/provider.ts
CHANGED
|
@@ -130,17 +130,6 @@ export async function selectProvider(
|
|
|
130
130
|
|
|
131
131
|
if (forced === "cdp") return new CdpBrowserProvider(settings);
|
|
132
132
|
|
|
133
|
-
// Transparent, idempotent native-host install: ensure the native-messaging
|
|
134
|
-
// manifest is present + correct on every init so the extension can connect.
|
|
135
|
-
// Removes the need for a manual `xcsh chrome setup` step. Best-effort — any
|
|
136
|
-
// failure surfaces as the actionable "extension not connected" error below.
|
|
137
|
-
try {
|
|
138
|
-
const { ensureNativeHostInstalled } = await import("../cli/chrome-cli");
|
|
139
|
-
ensureNativeHostInstalled();
|
|
140
|
-
} catch {
|
|
141
|
-
/* best-effort; never block init on setup */
|
|
142
|
-
}
|
|
143
|
-
|
|
144
133
|
try {
|
|
145
134
|
const server = opts?.bridgeServer ?? (await startBridgeServer());
|
|
146
135
|
const deadline = Date.now() + probeMs;
|
|
@@ -152,13 +141,13 @@ export async function selectProvider(
|
|
|
152
141
|
}
|
|
153
142
|
if (forced === "extension") {
|
|
154
143
|
// Don't tear down the bridge or fall back — fail with an actionable
|
|
155
|
-
// install path.
|
|
156
|
-
//
|
|
144
|
+
// install path. A no-connect almost always means the extension itself
|
|
145
|
+
// isn't installed/enabled (the bridge listens on loopback already).
|
|
157
146
|
const { WEB_STORE_URL } = await import("../cli/chrome-cli");
|
|
158
147
|
throw new Error(
|
|
159
148
|
`The xcsh Chrome extension is not connected, so deterministic console automation cannot run. ` +
|
|
160
149
|
`Install it from the Chrome Web Store and keep it enabled:\n ${WEB_STORE_URL}\n` +
|
|
161
|
-
`Then reload Chrome and retry. (Waited ${probeMs}ms
|
|
150
|
+
`Then reload Chrome and retry. (Waited ${probeMs}ms.)`,
|
|
162
151
|
);
|
|
163
152
|
}
|
|
164
153
|
await server.close();
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Chrome CLI command handlers.
|
|
3
3
|
*
|
|
4
|
-
* Backs both `xcsh chrome [status|relaunch]` and the `/chrome` REPL slash
|
|
4
|
+
* Backs both `xcsh chrome [status|relaunch|setup]` and the `/chrome` REPL slash
|
|
5
5
|
* command. `renderStatus` is a pure formatter so it can be unit-tested without
|
|
6
6
|
* touching Chrome, settings, or the network.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import * as fs from "node:fs";
|
|
10
|
-
import * as os from "node:os";
|
|
11
|
-
import * as path from "node:path";
|
|
12
9
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
10
|
+
import { resolvePort } from "../browser/extension-bridge";
|
|
13
11
|
|
|
14
12
|
type Settings = { get(key: string): unknown };
|
|
15
13
|
|
|
16
14
|
export type ChromeAction = "status" | "relaunch" | "setup";
|
|
17
15
|
|
|
18
16
|
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
19
|
-
export const EXTENSION_IDS = [EXTENSION_ID];
|
|
20
17
|
|
|
21
18
|
/**
|
|
22
19
|
* Baked-in Chrome Web Store URL for the xcsh console-automation extension.
|
|
@@ -25,141 +22,6 @@ export const EXTENSION_IDS = [EXTENSION_ID];
|
|
|
25
22
|
*/
|
|
26
23
|
export const WEB_STORE_URL = `https://chromewebstore.google.com/detail/${EXTENSION_ID}`;
|
|
27
24
|
|
|
28
|
-
const NATIVE_HOST_NAME = "com.xcsh.xcsh.chrome_host";
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* How Chrome should invoke the native-messaging relay. A compiled `xcsh`
|
|
32
|
-
* binary resolves `chrome-host` as a subcommand directly (path=xcsh,
|
|
33
|
-
* args=["chrome-host"]). Under a dev run (`bun src/cli.ts …`) `process.execPath`
|
|
34
|
-
* is `bun`, which needs the entry script path to resolve the subcommand —
|
|
35
|
-
* otherwise Chrome launches `bun chrome-host`, which fails, and the bridge never
|
|
36
|
-
* comes up. This makes the manifest correct for BOTH invocations.
|
|
37
|
-
*/
|
|
38
|
-
/** Find a compiled `xcsh` binary on PATH (self-contained; survives Chrome's stripped env). */
|
|
39
|
-
function defaultResolveXcshBin(): string | null {
|
|
40
|
-
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
41
|
-
for (const dir of dirs) {
|
|
42
|
-
const candidate = path.join(dir, "xcsh");
|
|
43
|
-
try {
|
|
44
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
45
|
-
return candidate;
|
|
46
|
-
} catch {
|
|
47
|
-
/* not on this PATH entry */
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function nativeHostInvocation(
|
|
54
|
-
argv = process.argv,
|
|
55
|
-
execPath = process.execPath,
|
|
56
|
-
resolveXcshBin: () => string | null = defaultResolveXcshBin,
|
|
57
|
-
): {
|
|
58
|
-
binPath: string;
|
|
59
|
-
args: string[];
|
|
60
|
-
} {
|
|
61
|
-
const base = path.basename(execPath).toLowerCase();
|
|
62
|
-
if (base.startsWith("bun")) {
|
|
63
|
-
// Chrome launches native hosts with a stripped environment; `bun <entry>.ts`
|
|
64
|
-
// then fails dependency resolution and the host exits immediately ("Native
|
|
65
|
-
// host has exited"). A compiled, self-contained xcsh binary survives — so
|
|
66
|
-
// prefer it for the relay whenever one is resolvable on PATH.
|
|
67
|
-
const xcsh = resolveXcshBin();
|
|
68
|
-
if (xcsh) return { binPath: xcsh, args: ["chrome-host"] };
|
|
69
|
-
const script = argv[1];
|
|
70
|
-
if (script && (script.endsWith(".ts") || script.endsWith(".js"))) {
|
|
71
|
-
return { binPath: execPath, args: [script, "chrome-host"] };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return { binPath: execPath, args: ["chrome-host"] };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Idempotently ensure the native-messaging host manifest is installed and
|
|
79
|
-
* correct. Safe to call on every console-automation init — it only writes when
|
|
80
|
-
* the manifest is missing or differs, so it's transparent to the user and
|
|
81
|
-
* removes the need to run `xcsh chrome setup` by hand. Returns whether a write
|
|
82
|
-
* occurred and the manifest path.
|
|
83
|
-
*/
|
|
84
|
-
export function ensureNativeHostInstalled(opts?: {
|
|
85
|
-
platform?: NodeJS.Platform;
|
|
86
|
-
home?: string;
|
|
87
|
-
argv?: string[];
|
|
88
|
-
execPath?: string;
|
|
89
|
-
devExtensionId?: string;
|
|
90
|
-
}): { manifestPath: string; changed: boolean } {
|
|
91
|
-
const platform = opts?.platform ?? process.platform;
|
|
92
|
-
const home = opts?.home ?? os.homedir();
|
|
93
|
-
if (platform === "win32") return { manifestPath: "", changed: false };
|
|
94
|
-
const { binPath, args } = nativeHostInvocation(opts?.argv, opts?.execPath);
|
|
95
|
-
const dir = nativeHostDir(platform, home);
|
|
96
|
-
const manifestPath = path.join(dir, `${NATIVE_HOST_NAME}.json`);
|
|
97
|
-
// Dev affordance: an unpacked/local extension build loads under a different
|
|
98
|
-
// (key- or path-derived) id than the published one. Setting
|
|
99
|
-
// XCSH_DEV_EXTENSION_ID lets the native host also trust that build so you can
|
|
100
|
-
// iterate locally without editing this file. Ignored in normal use.
|
|
101
|
-
const devId = (opts?.devExtensionId ?? process.env.XCSH_DEV_EXTENSION_ID)?.trim();
|
|
102
|
-
const ids = devId && !EXTENSION_IDS.includes(devId) ? [...EXTENSION_IDS, devId] : EXTENSION_IDS;
|
|
103
|
-
const desired = JSON.stringify(
|
|
104
|
-
{
|
|
105
|
-
name: NATIVE_HOST_NAME,
|
|
106
|
-
description: "xcsh Chrome native-messaging host",
|
|
107
|
-
path: binPath,
|
|
108
|
-
args,
|
|
109
|
-
type: "stdio",
|
|
110
|
-
allowed_origins: ids.map(id => `chrome-extension://${id}/`),
|
|
111
|
-
},
|
|
112
|
-
null,
|
|
113
|
-
2,
|
|
114
|
-
);
|
|
115
|
-
let existing: string | null = null;
|
|
116
|
-
try {
|
|
117
|
-
existing = fs.readFileSync(manifestPath, "utf8");
|
|
118
|
-
} catch {
|
|
119
|
-
/* missing — will write */
|
|
120
|
-
}
|
|
121
|
-
if (existing === desired) return { manifestPath, changed: false };
|
|
122
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
-
fs.writeFileSync(manifestPath, desired);
|
|
124
|
-
return { manifestPath, changed: true };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function nativeHostDir(platform: NodeJS.Platform, home: string): string {
|
|
128
|
-
if (platform === "darwin")
|
|
129
|
-
return path.join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
|
|
130
|
-
return path.join(home, ".config", "google-chrome", "NativeMessagingHosts"); // linux (win32 handled separately — out of slice scope; throw)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function writeNativeHostManifest(opts: {
|
|
134
|
-
platform?: NodeJS.Platform;
|
|
135
|
-
home?: string;
|
|
136
|
-
xcshBinPath: string;
|
|
137
|
-
extensionIds: string[];
|
|
138
|
-
write?: (p: string, c: string) => void;
|
|
139
|
-
}): { manifestPath: string } {
|
|
140
|
-
const platform = opts.platform ?? process.platform;
|
|
141
|
-
const home = opts.home ?? os.homedir();
|
|
142
|
-
if (platform === "win32") throw new Error("xcsh chrome setup on Windows is not in the vertical slice");
|
|
143
|
-
const dir = nativeHostDir(platform, home);
|
|
144
|
-
const manifestPath = path.join(dir, `${NATIVE_HOST_NAME}.json`);
|
|
145
|
-
const manifest = {
|
|
146
|
-
name: NATIVE_HOST_NAME,
|
|
147
|
-
description: "xcsh Chrome native-messaging host",
|
|
148
|
-
path: opts.xcshBinPath,
|
|
149
|
-
args: ["chrome-host"],
|
|
150
|
-
type: "stdio",
|
|
151
|
-
allowed_origins: opts.extensionIds.map(id => `chrome-extension://${id}/`),
|
|
152
|
-
};
|
|
153
|
-
const write =
|
|
154
|
-
opts.write ??
|
|
155
|
-
((p, c) => {
|
|
156
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
157
|
-
fs.writeFileSync(p, c);
|
|
158
|
-
});
|
|
159
|
-
write(manifestPath, JSON.stringify(manifest, null, 2));
|
|
160
|
-
return { manifestPath };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
25
|
export function renderStatus(s: BrowserProviderStatus): string {
|
|
164
26
|
const yn = (b: boolean) => (b ? "yes" : "no");
|
|
165
27
|
return [
|
|
@@ -180,12 +42,12 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
180
42
|
const provider = new CdpBrowserProvider(settings);
|
|
181
43
|
if (action === "status") return renderStatus(await provider.status());
|
|
182
44
|
if (action === "setup") {
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
const state = changed ? "Installed" : "Verified (already current)";
|
|
45
|
+
// The extension connects directly over a loopback WebSocket — no native-messaging
|
|
46
|
+
// host manifest to install. Report the port so the user can point the extension at it.
|
|
47
|
+
const port = resolvePort();
|
|
187
48
|
return (
|
|
188
|
-
|
|
49
|
+
`The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ` +
|
|
50
|
+
`ws://127.0.0.1:${port} (override with XCSH_BRIDGE_PORT).\n` +
|
|
189
51
|
`Install/keep the xcsh Chrome extension from the Web Store, then it can drive your real Chrome:\n ${WEB_STORE_URL}`
|
|
190
52
|
);
|
|
191
53
|
}
|
package/src/cli.ts
CHANGED
|
@@ -52,7 +52,6 @@ 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/chrome-host").then(m => m.default) },
|
|
56
55
|
{ name: "grep", load: () => import("./commands/grep").then(m => m.default) },
|
|
57
56
|
{ name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
|
|
58
57
|
{ name: "read", load: () => import("./commands/read").then(m => m.default) },
|
|
@@ -110,14 +109,7 @@ if (process.env.XCSH_SMOKE_TEST_SPECS === "1") {
|
|
|
110
109
|
process.exit(domainCount > 0 && categoryCount > 0 ? 0 : 1);
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// collectors) for it: the relay never needs a locale, and running full init per
|
|
116
|
-
// launch both wastes work and floods logs with "Applied locale" during reconnect
|
|
117
|
-
// churn. Only the user-facing commands need language discovery.
|
|
118
|
-
if (process.argv[2] !== "chrome-host") {
|
|
119
|
-
const { discoverAndApplyLanguage } = await import("./discovery/language");
|
|
120
|
-
await discoverAndApplyLanguage();
|
|
121
|
-
}
|
|
112
|
+
const { discoverAndApplyLanguage } = await import("./discovery/language");
|
|
113
|
+
await discoverAndApplyLanguage();
|
|
122
114
|
|
|
123
115
|
await runCli(process.argv.slice(2));
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.51.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.51.6",
|
|
21
|
+
"commit": "2a072802e0f6e75cb5f1ada167c64fed52cd39b1",
|
|
22
|
+
"shortCommit": "2a07280",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.51.
|
|
25
|
-
"commitDate": "2026-06-
|
|
26
|
-
"buildDate": "2026-06-
|
|
24
|
+
"tag": "v19.51.6",
|
|
25
|
+
"commitDate": "2026-06-27T02:46:04Z",
|
|
26
|
+
"buildDate": "2026-06-27T03:14:10.843Z",
|
|
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.51.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/2a072802e0f6e75cb5f1ada167c64fed52cd39b1",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.51.6"
|
|
33
33
|
};
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `chrome-host` — native-messaging relay subcommand.
|
|
3
|
-
*
|
|
4
|
-
* Launched by Chrome as the native-messaging host. It is a pure relay between
|
|
5
|
-
* Chrome's stdio (native-messaging framing: 4-byte LE length + JSON) and xcsh's
|
|
6
|
-
* Unix socket at `~/.xcsh/chrome-bridge.sock` (newline-delimited JSON). No
|
|
7
|
-
* business logic lives here.
|
|
8
|
-
*/
|
|
9
|
-
import { homedir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
12
|
-
import { decodeNm, encodeNm } from "../browser/native-messaging";
|
|
13
|
-
|
|
14
|
-
const SOCKET_PATH = process.env.XCSH_BRIDGE_SOCKET ?? join(homedir(), ".xcsh", "chrome-bridge.sock");
|
|
15
|
-
|
|
16
|
-
// Diagnostic relay tracing, OFF unless ~/.xcsh/chrome-host-debug exists. Security:
|
|
17
|
-
// writes to a 0600 file in the user's home, lifecycle + byte-counts ONLY — never
|
|
18
|
-
// message content — and never to stdout (that would corrupt the NM stream).
|
|
19
|
-
function dbg(msg: string): void {
|
|
20
|
-
try {
|
|
21
|
-
const fs = require("node:fs");
|
|
22
|
-
const home = process.env.HOME || homedir();
|
|
23
|
-
if (!fs.existsSync(join(home, ".xcsh", "chrome-host-debug"))) return;
|
|
24
|
-
const fd = fs.openSync(join(home, ".xcsh", "chrome-host.log"), "a", 0o600);
|
|
25
|
-
fs.appendFileSync(fd, `${new Date().toISOString()} pid=${process.pid} ${msg}\n`);
|
|
26
|
-
fs.closeSync(fd);
|
|
27
|
-
} catch {
|
|
28
|
-
/* logging must never break the relay */
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export default class ChromeHost extends Command {
|
|
33
|
-
static description = "Native-messaging relay between Chrome and the xcsh bridge socket (internal)";
|
|
34
|
-
|
|
35
|
-
async run(): Promise<void> {
|
|
36
|
-
let socket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
|
|
37
|
-
let socketBuffer = "";
|
|
38
|
-
dbg(`start sock=${SOCKET_PATH} argc=${process.argv.length}`);
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
dbg("Bun.connect: begin");
|
|
42
|
-
socket = await Bun.connect({
|
|
43
|
-
unix: SOCKET_PATH,
|
|
44
|
-
socket: {
|
|
45
|
-
data(_sock, chunk) {
|
|
46
|
-
// Socket → Chrome: NDJSON lines → native-messaging frames.
|
|
47
|
-
socketBuffer += new TextDecoder().decode(chunk);
|
|
48
|
-
let newlineIndex = socketBuffer.indexOf("\n");
|
|
49
|
-
while (newlineIndex !== -1) {
|
|
50
|
-
const line = socketBuffer.slice(0, newlineIndex);
|
|
51
|
-
socketBuffer = socketBuffer.slice(newlineIndex + 1);
|
|
52
|
-
if (line.length > 0) {
|
|
53
|
-
dbg(`socket→chrome ${line.length}B`);
|
|
54
|
-
process.stdout.write(encodeNm(JSON.parse(line)));
|
|
55
|
-
}
|
|
56
|
-
newlineIndex = socketBuffer.indexOf("\n");
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
close() {
|
|
60
|
-
dbg("bridge socket closed → exit");
|
|
61
|
-
process.exit(0);
|
|
62
|
-
},
|
|
63
|
-
error() {
|
|
64
|
-
dbg("bridge socket error → exit");
|
|
65
|
-
process.exit(0);
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
dbg("Bun.connect: resolved (connected)");
|
|
70
|
-
} catch (e) {
|
|
71
|
-
// xcsh not running — write nothing, exit cleanly. The extension retries.
|
|
72
|
-
dbg(`Bun.connect: threw → exit: ${e instanceof Error ? e.message : String(e)}`);
|
|
73
|
-
process.exit(0);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Chrome stdin → socket: native-messaging frames → NDJSON lines.
|
|
77
|
-
dbg("reading chrome stdin");
|
|
78
|
-
let stdinBuffer: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
|
79
|
-
for await (const chunk of process.stdin) {
|
|
80
|
-
const bytes = new Uint8Array(chunk);
|
|
81
|
-
const next = new Uint8Array(stdinBuffer.length + bytes.length);
|
|
82
|
-
next.set(stdinBuffer, 0);
|
|
83
|
-
next.set(bytes, stdinBuffer.length);
|
|
84
|
-
const { messages, rest } = decodeNm(next);
|
|
85
|
-
stdinBuffer = rest;
|
|
86
|
-
for (const msg of messages) {
|
|
87
|
-
dbg(`chrome→socket ${JSON.stringify(msg).length}B`);
|
|
88
|
-
socket.write(`${JSON.stringify(msg)}\n`);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Chrome closed stdin — tear down and exit.
|
|
93
|
-
dbg("chrome stdin EOF → exit");
|
|
94
|
-
socket.end();
|
|
95
|
-
process.exit(0);
|
|
96
|
-
}
|
|
97
|
-
}
|