@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.5",
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.4",
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 * as fs from "node:fs";
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
- type BridgeData = { buf: string };
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
- * Unix-socket server bridging xcsh to the Chrome extension's native-messaging
66
- * host. Speaks newline-delimited JSON: requests `{type:"tool_request",...}`,
67
- * replies `{type:"tool_result",...}`, plus `{type:"ping"|"pong"}`. Tracks a
68
- * single connected client and correlates replies via {@link PendingRequests}.
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
- #listener: UnixSocketListener<BridgeData> | null = null;
73
- #client: Socket<BridgeData> | null = null;
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 Unix socket path. Called by {@link startBridgeServer}. */
90
- listen(socketPath: string): void {
91
- this.#listener = Bun.listen<BridgeData>({
92
- unix: socketPath,
93
- socket: {
94
- open: socket => {
95
- socket.data = { buf: "" };
96
- this.#client = socket;
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
- data: (socket, chunk) => {
100
- this.#onData(socket, chunk);
101
- },
102
- close: socket => {
103
- this.#onClose(socket);
127
+ message: (ws, message) => {
128
+ this.#handleMessage(ws, message);
104
129
  },
105
- error: socket => {
106
- this.#onClose(socket);
130
+ close: ws => {
131
+ this.#onClose(ws);
107
132
  },
108
133
  },
109
134
  });
110
135
  }
111
136
 
112
- #onData(socket: Socket<BridgeData>, chunk: Buffer): void {
113
- socket.data.buf += chunk.toString("utf8");
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(line);
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
- this.#write(socket, { type: "pong" });
151
+ ws.send(JSON.stringify({ type: "pong" }));
137
152
  }
138
153
  }
139
154
 
140
- #onClose(socket: Socket<BridgeData>): void {
141
- if (this.#client !== socket) return;
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
- this.#write(client, { type: "tool_request", id, tool, params });
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?.end();
173
+ this.#client?.close();
163
174
  this.#client = null;
164
- this.#listener?.stop(true);
165
- this.#listener = null;
175
+ this.#server?.stop(true);
176
+ this.#server = null;
166
177
  }
167
178
  }
168
179
 
169
180
  /**
170
- * Resolve the default socket path (`~/.xcsh/chrome-bridge.sock`), ensure the
171
- * directory exists, remove any stale socket, start the {@link BridgeServer},
172
- * and tighten the socket permissions to owner-only (0600).
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(socketPath?: string): Promise<BridgeServer> {
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(resolved);
180
- fs.chmodSync(resolved, 0o600);
187
+ server.listen(resolvePort(port), opts);
181
188
  return server;
182
189
  }
@@ -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. The native host is auto-installed above, so a no-connect
156
- // almost always means the extension itself isn't installed/enabled.
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; the native-messaging host was auto-installed.)`,
150
+ `Then reload Chrome and retry. (Waited ${probeMs}ms.)`,
162
151
  );
163
152
  }
164
153
  await server.close();
@@ -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
- // Idempotent: only writes when missing/stale. This is also run automatically
184
- // on console-automation init, so `xcsh chrome setup` is optional now.
185
- const { manifestPath, changed } = ensureNativeHostInstalled();
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
- `${state} native-messaging host manifest at ${manifestPath} (extensions ${EXTENSION_IDS.join(", ")}).\n` +
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
- // The `chrome-host` native-messaging relay is a hot path — Chrome (re)launches it
114
- // on every (re)connect attempt. Skip language discovery (profile I/O + OS
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.4",
21
- "commit": "4219192c5e072c0d7d4a54148b38b27b47d520f8",
22
- "shortCommit": "4219192",
20
+ "version": "19.51.6",
21
+ "commit": "2a072802e0f6e75cb5f1ada167c64fed52cd39b1",
22
+ "shortCommit": "2a07280",
23
23
  "branch": "main",
24
- "tag": "v19.51.5",
25
- "commitDate": "2026-06-27T00:31:04Z",
26
- "buildDate": "2026-06-27T01:00:15.899Z",
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/4219192c5e072c0d7d4a54148b38b27b47d520f8",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.51.4"
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
- }