@f5xc-salesdemos/xcsh 19.37.0 → 19.38.0

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": "@f5xc-salesdemos/xcsh",
4
- "version": "19.37.0",
4
+ "version": "19.38.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -51,13 +51,13 @@
51
51
  "dependencies": {
52
52
  "@agentclientprotocol/sdk": "0.16.1",
53
53
  "@mozilla/readability": "^0.6",
54
- "@f5xc-salesdemos/xcsh-stats": "19.37.0",
55
- "@f5xc-salesdemos/pi-agent-core": "19.37.0",
56
- "@f5xc-salesdemos/pi-ai": "19.37.0",
57
- "@f5xc-salesdemos/pi-natives": "19.37.0",
58
- "@f5xc-salesdemos/pi-resource-management": "19.37.0",
59
- "@f5xc-salesdemos/pi-tui": "19.37.0",
60
- "@f5xc-salesdemos/pi-utils": "19.37.0",
54
+ "@f5xc-salesdemos/xcsh-stats": "19.38.0",
55
+ "@f5xc-salesdemos/pi-agent-core": "19.38.0",
56
+ "@f5xc-salesdemos/pi-ai": "19.38.0",
57
+ "@f5xc-salesdemos/pi-natives": "19.38.0",
58
+ "@f5xc-salesdemos/pi-resource-management": "19.38.0",
59
+ "@f5xc-salesdemos/pi-tui": "19.38.0",
60
+ "@f5xc-salesdemos/pi-utils": "19.38.0",
61
61
  "@sinclair/typebox": "^0.34",
62
62
  "@xterm/headless": "^6.0",
63
63
  "ajv": "^8.20",
@@ -1,10 +1,11 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import type { Browser, Page } from "puppeteer";
4
5
  import { assertLoopbackBrowserUrl, pickCoDrivePage, resolveBrowserConnectUrl } from "../tools/browser";
5
6
  import { locateChrome } from "./chrome-locate";
6
7
 
7
- export type AcquireMode = "attached" | "launched-default" | "launched-dedicated";
8
+ export type AcquireMode = "attached" | "launched-default" | "launched-dedicated" | "relaunched-default";
8
9
 
9
10
  const DEFAULT_DEBUG_PORT = 9222;
10
11
 
@@ -48,11 +49,46 @@ async function withLaunchTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
48
49
  }
49
50
 
50
51
  export function buildLaunchArgs(opts: { profileDir?: string; debugPort: number }): string[] {
51
- const args = [`--remote-debugging-port=${opts.debugPort}`, "--no-first-run", "--no-default-browser-check"];
52
+ const args = [
53
+ `--remote-debugging-port=${opts.debugPort}`,
54
+ "--remote-debugging-address=127.0.0.1",
55
+ "--no-first-run",
56
+ "--no-default-browser-check",
57
+ ];
52
58
  if (opts.profileDir) args.push(`--user-data-dir=${opts.profileDir}`);
53
59
  return args;
54
60
  }
55
61
 
62
+ export type AcquireAction = "attach" | "launch" | "relaunch" | "dedicated" | "no-chrome";
63
+
64
+ export function decideAcquireAction(state: {
65
+ debuggableNow: boolean;
66
+ chromeRunning: boolean;
67
+ chromeInstalled: boolean;
68
+ allowRelaunch: boolean;
69
+ }): AcquireAction {
70
+ if (!state.chromeInstalled) return "no-chrome";
71
+ if (state.debuggableNow) return "attach";
72
+ if (!state.chromeRunning) return "launch";
73
+ return state.allowRelaunch ? "relaunch" : "dedicated";
74
+ }
75
+
76
+ /** Graceful (never force) quit command for the user's Chrome, per OS. */
77
+ export function quitChromeCommand(
78
+ platform: NodeJS.Platform = process.platform,
79
+ ): { cmd: string; args: string[] } | null {
80
+ switch (platform) {
81
+ case "darwin":
82
+ return { cmd: "osascript", args: ["-e", 'quit app "Google Chrome"'] };
83
+ case "linux":
84
+ return { cmd: "pkill", args: ["-TERM", "-x", "chrome"] };
85
+ case "win32":
86
+ return { cmd: "taskkill", args: ["/IM", "chrome.exe"] };
87
+ default:
88
+ return null;
89
+ }
90
+ }
91
+
56
92
  export function isProfileLockError(message: string): boolean {
57
93
  // Chrome's own SingletonLock messages, plus Puppeteer's message when an
58
94
  // explicit --user-data-dir is already held by a running browser
@@ -62,6 +98,39 @@ export function isProfileLockError(message: string): boolean {
62
98
  );
63
99
  }
64
100
 
101
+ function defaultExec(cmd: string, args: string[]): { code: number } {
102
+ try {
103
+ execFileSync(cmd, args, { stdio: "ignore" });
104
+ return { code: 0 };
105
+ } catch (e) {
106
+ const code = (e as { status?: number }).status;
107
+ return { code: typeof code === "number" ? code : 1 };
108
+ }
109
+ }
110
+
111
+ export function isChromeRunning(
112
+ opts: { platform?: NodeJS.Platform; exec?: (cmd: string, args: string[]) => { code: number } } = {},
113
+ ): boolean {
114
+ const platform = opts.platform ?? process.platform;
115
+ const exec = opts.exec ?? defaultExec;
116
+ if (platform === "win32") {
117
+ // tasklist always exits 0; use FILTER so a no-match is a non-zero/empty result.
118
+ return exec("tasklist", ["/FI", "IMAGENAME eq chrome.exe", "/NH"]).code === 0;
119
+ }
120
+ // darwin/linux: pgrep exits 0 when a match exists, 1 otherwise.
121
+ const pattern = platform === "darwin" ? "Google Chrome" : "chrome";
122
+ return exec("pgrep", ["-x", pattern]).code === 0;
123
+ }
124
+
125
+ async function waitForChromeExit(timeoutMs = 8000, pollMs = 300): Promise<boolean> {
126
+ const deadline = Date.now() + timeoutMs;
127
+ while (Date.now() < deadline) {
128
+ if (!isChromeRunning()) return true;
129
+ await new Promise(r => setTimeout(r, pollMs));
130
+ }
131
+ return false;
132
+ }
133
+
65
134
  async function tryAttach(browserURL: string): Promise<{ browser: Browser; page: Page } | null> {
66
135
  const puppeteer = (await import("puppeteer")).default;
67
136
  try {
@@ -82,6 +151,7 @@ async function launch(executablePath: string, args: string[]): Promise<Browser>
82
151
  export async function acquirePage(opts: {
83
152
  settings: { get(key: string): unknown };
84
153
  debugPort?: number;
154
+ allowRelaunch?: boolean;
85
155
  }): Promise<{ browser: Browser; page: Page; mode: AcquireMode }> {
86
156
  const debugPort = opts.debugPort ?? DEFAULT_DEBUG_PORT;
87
157
  const configuredUrl = resolveBrowserConnectUrl(opts.settings);
@@ -127,10 +197,35 @@ export async function acquirePage(opts: {
127
197
  // Profile locked (Chrome already running) or the launch timed out (handoff to a
128
198
  // running instance) → fall through to a dedicated, xcsh-owned profile.
129
199
  if (!isProfileLockError(msg) && !msg.includes("launch timed out")) throw err;
200
+
201
+ // Resolve relaunch consent: explicit opt, else the setting.
202
+ const allowRelaunch = opts.allowRelaunch ?? opts.settings.get("browser.allowChromeRelaunch") === true;
203
+ // 3) Chrome running without the port → consented quit + relaunch on the real profile.
204
+ if (allowRelaunch) {
205
+ const quit = quitChromeCommand();
206
+ if (quit) {
207
+ try {
208
+ execFileSync(quit.cmd, quit.args, { stdio: "ignore" });
209
+ } catch {
210
+ /* ignore quit errors; verify via waitForChromeExit */
211
+ }
212
+ const exited = await waitForChromeExit();
213
+ if (exited) {
214
+ const browser = await withLaunchTimeout(
215
+ launch(located.path, buildLaunchArgs({ debugPort, profileDir: defaultDir })),
216
+ 12_000,
217
+ );
218
+ const pages = await browser.pages();
219
+ const page = pages.length ? pages[0]! : await browser.newPage();
220
+ return { browser, page, mode: "relaunched-default" };
221
+ }
222
+ // quit timed out → DO NOT relaunch (avoid two instances / data loss); fall through to dedicated.
223
+ }
224
+ }
130
225
  }
131
226
  }
132
227
 
133
- // 3) Default profile unavailable (locked / handoff / unsupported platform) → dedicated
228
+ // 4) Default profile unavailable (locked / handoff / unsupported platform) → dedicated
134
229
  // xcsh-owned profile. A previous xcsh-launched Chrome on this profile may still be
135
230
  // running (close() only disconnects, never terminates), so this launch can hit the
136
231
  // same SingletonLock/handoff and hang. Guard it with a timeout; there is no further
@@ -0,0 +1,51 @@
1
+ import type { Page } from "puppeteer";
2
+ import * as actions from "./actions";
3
+ import type { PageActions } from "./page-actions";
4
+
5
+ /** CDP-backed `PageActions`: wraps a Puppeteer `Page` and delegates to `actions.ts`. */
6
+ export class CdpPageActions implements PageActions {
7
+ #page: Page;
8
+ constructor(page: Page) {
9
+ this.#page = page;
10
+ }
11
+
12
+ async goto(url: string, opts?: { waitUntil?: string; timeout?: number }): Promise<void> {
13
+ type GotoOptions = NonNullable<Parameters<Page["goto"]>[1]>;
14
+ await this.#page.goto(url, {
15
+ waitUntil: (opts?.waitUntil as GotoOptions["waitUntil"]) ?? "networkidle2",
16
+ timeout: opts?.timeout,
17
+ });
18
+ }
19
+
20
+ async click(selector: string, context?: string): Promise<void> {
21
+ await actions.click(this.#page, selector, context);
22
+ }
23
+
24
+ async fill(selector: string, value: string, context?: string): Promise<void> {
25
+ await actions.fill(this.#page, selector, value, context);
26
+ }
27
+
28
+ async selectOption(selector: string, value: string, context?: string): Promise<void> {
29
+ await actions.selectOption(this.#page, selector, value, context);
30
+ }
31
+
32
+ async scrollIntoView(selector: string, context?: string): Promise<void> {
33
+ await actions.scrollIntoView(this.#page, selector, context);
34
+ }
35
+
36
+ async pressKey(key: string): Promise<void> {
37
+ await actions.pressKey(this.#page, key);
38
+ }
39
+
40
+ async assertText(selector: string, expected: string, context?: string): Promise<void> {
41
+ await actions.assertText(this.#page, selector, expected, context);
42
+ }
43
+
44
+ async waitFor(selector: string, context?: string, timeoutMs?: number): Promise<void> {
45
+ await actions.waitFor(this.#page, selector, context, timeoutMs);
46
+ }
47
+
48
+ async screenshot(file: string): Promise<void> {
49
+ await actions.screenshot(this.#page, file);
50
+ }
51
+ }
@@ -0,0 +1,182 @@
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";
6
+
7
+ export interface ToolResult {
8
+ content: unknown;
9
+ is_error: boolean;
10
+ }
11
+
12
+ /**
13
+ * Id-correlated pending-request registry. Pure (no socket I/O) so it can be
14
+ * unit-tested directly. Each {@link create} returns a fresh id and a promise
15
+ * that is settled by a later {@link resolve} (matching id) or {@link rejectAll}.
16
+ */
17
+ export class PendingRequests {
18
+ #m = new Map<
19
+ string,
20
+ {
21
+ resolve: (r: ToolResult) => void;
22
+ reject: (e: Error) => void;
23
+ timer: ReturnType<typeof setTimeout>;
24
+ }
25
+ >();
26
+
27
+ create(timeoutMs: number): { id: string; promise: Promise<ToolResult> } {
28
+ const id = randomUUID();
29
+ let resolve!: (r: ToolResult) => void;
30
+ let reject!: (e: Error) => void;
31
+ const promise = new Promise<ToolResult>((res, rej) => {
32
+ resolve = res;
33
+ reject = rej;
34
+ });
35
+ const timer = setTimeout(() => {
36
+ if (this.#m.delete(id)) reject(new Error(`bridge request ${id} timed out`));
37
+ }, timeoutMs);
38
+ this.#m.set(id, { resolve, reject, timer });
39
+ return { id, promise };
40
+ }
41
+
42
+ resolve(id: string, result: ToolResult): boolean {
43
+ const e = this.#m.get(id);
44
+ if (!e) return false;
45
+ clearTimeout(e.timer);
46
+ this.#m.delete(id);
47
+ e.resolve(result);
48
+ return true;
49
+ }
50
+
51
+ rejectAll(err: Error): void {
52
+ for (const e of this.#m.values()) {
53
+ clearTimeout(e.timer);
54
+ e.reject(err);
55
+ }
56
+ this.#m.clear();
57
+ }
58
+ }
59
+
60
+ const DEFAULT_TIMEOUT_MS = 30_000;
61
+
62
+ type BridgeData = { buf: string };
63
+
64
+ /**
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}.
69
+ */
70
+ export class BridgeServer {
71
+ #pending = new PendingRequests();
72
+ #listener: UnixSocketListener<BridgeData> | null = null;
73
+ #client: Socket<BridgeData> | null = null;
74
+ #onConnected: Array<() => void> = [];
75
+ #onDisconnected: Array<() => void> = [];
76
+
77
+ get connected(): boolean {
78
+ return this.#client !== null;
79
+ }
80
+
81
+ onConnected(cb: () => void): void {
82
+ this.#onConnected.push(cb);
83
+ }
84
+
85
+ onDisconnected(cb: () => void): void {
86
+ this.#onDisconnected.push(cb);
87
+ }
88
+
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;
97
+ for (const cb of this.#onConnected) cb();
98
+ },
99
+ data: (socket, chunk) => {
100
+ this.#onData(socket, chunk);
101
+ },
102
+ close: socket => {
103
+ this.#onClose(socket);
104
+ },
105
+ error: socket => {
106
+ this.#onClose(socket);
107
+ },
108
+ },
109
+ });
110
+ }
111
+
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 {
124
+ let msg: { type?: string; id?: string; content?: unknown; is_error?: boolean };
125
+ try {
126
+ msg = JSON.parse(line);
127
+ } catch {
128
+ return;
129
+ }
130
+ if (msg.type === "tool_result" && typeof msg.id === "string") {
131
+ this.#pending.resolve(msg.id, {
132
+ content: msg.content,
133
+ is_error: msg.is_error === true,
134
+ });
135
+ } else if (msg.type === "ping") {
136
+ this.#write(socket, { type: "pong" });
137
+ }
138
+ }
139
+
140
+ #onClose(socket: Socket<BridgeData>): void {
141
+ if (this.#client !== socket) return;
142
+ this.#client = null;
143
+ this.#pending.rejectAll(new Error("bridge client disconnected"));
144
+ for (const cb of this.#onDisconnected) cb();
145
+ }
146
+
147
+ #write(socket: Socket<BridgeData>, msg: unknown): void {
148
+ socket.write(`${JSON.stringify(msg)}\n`);
149
+ }
150
+
151
+ /** Send a `tool_request` to the connected client and await its `tool_result`. */
152
+ request(tool: string, params: unknown, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<ToolResult> {
153
+ const client = this.#client;
154
+ if (!client) return Promise.reject(new Error("bridge: no client connected"));
155
+ const { id, promise } = this.#pending.create(timeoutMs);
156
+ this.#write(client, { type: "tool_request", id, tool, params });
157
+ return promise;
158
+ }
159
+
160
+ async close(): Promise<void> {
161
+ this.#pending.rejectAll(new Error("bridge server closed"));
162
+ this.#client?.end();
163
+ this.#client = null;
164
+ this.#listener?.stop(true);
165
+ this.#listener = null;
166
+ }
167
+ }
168
+
169
+ /**
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).
173
+ */
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 });
178
+ const server = new BridgeServer();
179
+ server.listen(resolved);
180
+ fs.chmodSync(resolved, 0o600);
181
+ return server;
182
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Extension-backed {@link PageActions} implementation.
3
+ *
4
+ * Wraps an {@link ExtensionPage} (the Chrome extension bridge surface) so the
5
+ * catalogue-workflow runner can drive the extension through the exact same
6
+ * `PageActions` interface it uses for CDP. For tools that operate on a `ref`
7
+ * handle (`click`/`fill`/`selectOption`/`scrollIntoView`), resolution happens
8
+ * xcsh-side: read the AX tree, {@link resolveRef} the selector to a `ref`, then
9
+ * call the bridge tool. `assertText`/`waitFor` resolve selectors on the bridge
10
+ * (service-worker) side, so they pass through directly.
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { type ExtensionPage, resolveRef } from "./extension-provider";
15
+ import type { PageActions } from "./page-actions";
16
+
17
+ const ALLOWED_SCHEMES = new Set(["https:"]);
18
+ const CONSOLE_DOMAIN_RE = /\.(volterra\.us|console\.ves\.volterra\.io)$/;
19
+
20
+ export class ExtensionPageActions implements PageActions {
21
+ #ext: ExtensionPage;
22
+
23
+ constructor(ext: ExtensionPage) {
24
+ this.#ext = ext;
25
+ }
26
+
27
+ async goto(url: string): Promise<void> {
28
+ // Defense-in-depth: validate URL scheme + console-domain scope before sending to the extension.
29
+ // The extension SW also validates, but rejecting early is cleaner + prevents SSRF.
30
+ const parsed = new URL(url); // throws on malformed
31
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
32
+ throw new Error(`Disallowed URL scheme: ${parsed.protocol} (only https: is allowed)`);
33
+ }
34
+ if (!CONSOLE_DOMAIN_RE.test(parsed.hostname)) {
35
+ throw new Error(`URL "${parsed.hostname}" is not an F5 XC console domain`);
36
+ }
37
+ await this.#ext.navigate(url);
38
+ }
39
+
40
+ async click(selector: string, _context?: string): Promise<void> {
41
+ const tree = await this.#ext.readAx();
42
+ const ref = resolveRef(tree, selector);
43
+ await this.#ext.click(ref);
44
+ }
45
+
46
+ async fill(selector: string, value: string, _context?: string): Promise<void> {
47
+ const tree = await this.#ext.readAx();
48
+ const ref = resolveRef(tree, selector);
49
+ await this.#ext.formInput(ref, value);
50
+ }
51
+
52
+ async selectOption(selector: string, value: string, _context?: string): Promise<void> {
53
+ const tree = await this.#ext.readAx();
54
+ const ref = resolveRef(tree, selector);
55
+ await this.#ext.selectOption(ref, value);
56
+ }
57
+
58
+ async scrollIntoView(selector: string, _context?: string): Promise<void> {
59
+ const tree = await this.#ext.readAx();
60
+ const ref = resolveRef(tree, selector);
61
+ await this.#ext.scrollTo(ref);
62
+ }
63
+
64
+ async pressKey(key: string): Promise<void> {
65
+ await this.#ext.keyPress(key);
66
+ }
67
+
68
+ async assertText(selector: string, expected: string, context?: string): Promise<void> {
69
+ await this.#ext.assertText(selector, expected, context);
70
+ }
71
+
72
+ async waitFor(selector: string, context?: string, timeoutMs?: number): Promise<void> {
73
+ await this.#ext.waitFor(selector, context, timeoutMs);
74
+ }
75
+
76
+ async screenshot(file: string): Promise<void> {
77
+ // Defense-in-depth: resolve symlinks via realpathSync so a symlinked parent
78
+ // can't bypass the cwd containment check and write outside the working directory.
79
+ const cwdReal = fs.realpathSync(process.cwd());
80
+ const lexical = path.resolve(file);
81
+ let parentReal: string;
82
+ try {
83
+ parentReal = fs.realpathSync(path.dirname(lexical));
84
+ } catch {
85
+ throw new Error(`Screenshot directory does not exist: ${path.dirname(lexical)}`);
86
+ }
87
+ const resolved = path.join(parentReal, path.basename(lexical));
88
+ if (!resolved.startsWith(cwdReal + path.sep) && resolved !== cwdReal) {
89
+ throw new Error(`Screenshot path "${file}" resolves outside the working directory`);
90
+ }
91
+ const b64 = await this.#ext.screenshot();
92
+ await Bun.write(resolved, Buffer.from(b64, "base64"));
93
+ }
94
+ }