@f5-sales-demo/xcsh 19.54.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/scripts/generate-extension-capabilities.ts +1 -0
- package/src/browser/capabilities.generated.ts +15 -3
- package/src/browser/capabilities.json +12 -1
- package/src/browser/chat-conformance.json +1 -1
- package/src/browser/extension-bridge.ts +92 -53
- package/src/browser/extension-page-actions.ts +0 -1
- package/src/cli/chrome-cli.ts +67 -7
- 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 +152 -0
- package/src/extensibility/extensions/loader.ts +36 -1
- package/src/extensibility/extensions/types.ts +24 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +9 -7
- package/src/modes/controllers/extension-ui-controller.ts +14 -2
- package/src/sdk.ts +54 -0
- package/src/services/native-host-install.ts +63 -0
- package/src/services/session-context-binding.ts +49 -0
- package/src/services/xcsh-context.ts +17 -22
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "19.
|
|
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.
|
|
59
|
-
"@f5-sales-demo/pi-agent-core": "19.
|
|
60
|
-
"@f5-sales-demo/pi-ai": "19.
|
|
61
|
-
"@f5-sales-demo/pi-natives": "19.
|
|
62
|
-
"@f5-sales-demo/pi-resource-management": "19.
|
|
63
|
-
"@f5-sales-demo/pi-tui": "19.
|
|
64
|
-
"@f5-sales-demo/pi-utils": "19.
|
|
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",
|
|
@@ -54,6 +54,7 @@ const output = [
|
|
|
54
54
|
"export interface ExtensionCapabilities {",
|
|
55
55
|
"\treadonly version: string;",
|
|
56
56
|
"\treadonly contractVersion: string;",
|
|
57
|
+
"\treadonly multiPortDiscovery?: boolean;",
|
|
57
58
|
"\treadonly protocol: string;",
|
|
58
59
|
"\treadonly tools: readonly ExtensionToolDef[];",
|
|
59
60
|
"\treadonly features: Record<string, unknown>;",
|
|
@@ -18,6 +18,7 @@ export interface ExtensionToolDef {
|
|
|
18
18
|
export interface ExtensionCapabilities {
|
|
19
19
|
readonly version: string;
|
|
20
20
|
readonly contractVersion: string;
|
|
21
|
+
readonly multiPortDiscovery?: boolean;
|
|
21
22
|
readonly protocol: string;
|
|
22
23
|
readonly tools: readonly ExtensionToolDef[];
|
|
23
24
|
readonly features: Record<string, unknown>;
|
|
@@ -25,7 +26,8 @@ export interface ExtensionCapabilities {
|
|
|
25
26
|
|
|
26
27
|
export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
27
28
|
"version": "0.1.0",
|
|
28
|
-
"contractVersion": "1.
|
|
29
|
+
"contractVersion": "1.5.0",
|
|
30
|
+
"multiPortDiscovery": true,
|
|
29
31
|
"protocol": "tool_request/result",
|
|
30
32
|
"tools": [
|
|
31
33
|
{
|
|
@@ -613,6 +615,16 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
613
615
|
},
|
|
614
616
|
"flags": {}
|
|
615
617
|
},
|
|
618
|
+
{
|
|
619
|
+
"name": "diag_bridges",
|
|
620
|
+
"summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
|
|
621
|
+
"category": "read",
|
|
622
|
+
"params": {
|
|
623
|
+
"type": "object",
|
|
624
|
+
"properties": {}
|
|
625
|
+
},
|
|
626
|
+
"flags": {}
|
|
627
|
+
},
|
|
616
628
|
{
|
|
617
629
|
"name": "capture_login_flow",
|
|
618
630
|
"summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
|
|
@@ -813,6 +825,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
|
|
|
813
825
|
}
|
|
814
826
|
};
|
|
815
827
|
|
|
816
|
-
export const EXTENSION_CONTRACT_VERSION = "1.
|
|
828
|
+
export const EXTENSION_CONTRACT_VERSION = "1.5.0";
|
|
817
829
|
|
|
818
|
-
export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
|
|
830
|
+
export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "0.1.0",
|
|
3
|
-
"contractVersion": "1.
|
|
3
|
+
"contractVersion": "1.5.0",
|
|
4
|
+
"multiPortDiscovery": true,
|
|
4
5
|
"protocol": "tool_request/result",
|
|
5
6
|
"tools": [
|
|
6
7
|
{
|
|
@@ -539,6 +540,16 @@
|
|
|
539
540
|
},
|
|
540
541
|
"flags": {}
|
|
541
542
|
},
|
|
543
|
+
{
|
|
544
|
+
"name": "diag_bridges",
|
|
545
|
+
"summary": "List discovered xcsh bridges (port, tenant, env, sessionId, lastSeen) for multi-session diagnostics.",
|
|
546
|
+
"category": "read",
|
|
547
|
+
"params": {
|
|
548
|
+
"type": "object",
|
|
549
|
+
"properties": {}
|
|
550
|
+
},
|
|
551
|
+
"flags": {}
|
|
552
|
+
},
|
|
542
553
|
{
|
|
543
554
|
"name": "capture_login_flow",
|
|
544
555
|
"summary": "Diagnostic: captured login redirect chain annotated with tenant/env (Phase 0b).",
|
|
@@ -67,6 +67,25 @@ export function resolvePort(port?: number): number {
|
|
|
67
67
|
return DEFAULT_PORT;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Inclusive loopback discovery range for auto-selected bridge ports. */
|
|
71
|
+
export const PORT_RANGE_START = 19222;
|
|
72
|
+
export const PORT_RANGE_END = 19241;
|
|
73
|
+
|
|
74
|
+
/** Every port in the discovery range, lowest first. */
|
|
75
|
+
export function portCandidates(): number[] {
|
|
76
|
+
const out: number[] = [];
|
|
77
|
+
for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) out.push(p);
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The explicitly forced port (arg → XCSH_BRIDGE_PORT), or null to auto-select. */
|
|
82
|
+
export function resolveForcedPort(port?: number): number | null {
|
|
83
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0) return port;
|
|
84
|
+
const env = Number(process.env.XCSH_BRIDGE_PORT);
|
|
85
|
+
if (Number.isFinite(env) && env > 0) return env;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
/**
|
|
71
90
|
* Loopback WebSocket server bridging xcsh to the Chrome extension. Speaks JSON
|
|
72
91
|
* over WS frames: requests `{type:"tool_request",...}`, replies
|
|
@@ -89,7 +108,9 @@ export class BridgeServer {
|
|
|
89
108
|
/** Stable per-process session id, advertised to the extension on `hello`. */
|
|
90
109
|
#sessionId = `sess-${crypto.randomUUID()}`;
|
|
91
110
|
/** Provider of this process's tenant identity, answering the `hello` handshake. */
|
|
92
|
-
#sessionInfo:
|
|
111
|
+
#sessionInfo:
|
|
112
|
+
| (() => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean })
|
|
113
|
+
| null = null;
|
|
93
114
|
|
|
94
115
|
/** The port the WebSocket server is listening on (0 = not bound). */
|
|
95
116
|
get port(): number {
|
|
@@ -126,13 +147,15 @@ export class BridgeServer {
|
|
|
126
147
|
|
|
127
148
|
/** Set the tenant-identity provider that answers the extension's `hello`
|
|
128
149
|
* handshake with `{ tenant, env, apiUrl }` for THIS xcsh process/context. */
|
|
129
|
-
setSessionInfo(
|
|
150
|
+
setSessionInfo(
|
|
151
|
+
cb: () => { tenant: string | null; env: string | null; apiUrl: string | null; contextBound: boolean },
|
|
152
|
+
): void {
|
|
130
153
|
this.#sessionInfo = cb;
|
|
131
154
|
}
|
|
132
155
|
|
|
133
156
|
/** Push a tenant change to all connected panels (e.g. after `/context activate`). */
|
|
134
157
|
broadcastTenantChanged(): void {
|
|
135
|
-
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
|
|
158
|
+
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
|
|
136
159
|
for (const c of this.#clients.values()) {
|
|
137
160
|
try {
|
|
138
161
|
c.send(JSON.stringify({ type: "tenant_changed", sessionId: this.#sessionId, ...info }));
|
|
@@ -159,56 +182,58 @@ export class BridgeServer {
|
|
|
159
182
|
this.#resolveClient(channelId)?.send(JSON.stringify(payload));
|
|
160
183
|
}
|
|
161
184
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
185
|
+
#onOpen(ws: ServerWebSocket<undefined>): void {
|
|
186
|
+
// Assign a channel ID to each connection. For backwards compat (single
|
|
187
|
+
// extension), the first connection gets "default". Additional connections
|
|
188
|
+
// get "ch-1", "ch-2", etc. — supporting multi-tab parallelism.
|
|
189
|
+
const channelId = this.#clients.size === 0 ? "default" : `ch-${++this.#nextChannelIndex}`;
|
|
190
|
+
(ws as unknown as { channelId: string }).channelId = channelId;
|
|
191
|
+
this.#clients.set(channelId, ws);
|
|
192
|
+
// Start a heartbeat ping to keep the MV3 service worker alive.
|
|
193
|
+
// Chrome suspends idle SWs after ~30s; a ping every 15s prevents that.
|
|
194
|
+
if (!this.#heartbeat) {
|
|
195
|
+
this.#heartbeat = setInterval(() => {
|
|
196
|
+
for (const c of this.#clients.values()) {
|
|
197
|
+
try {
|
|
198
|
+
c.send(JSON.stringify({ type: "ping" }));
|
|
199
|
+
} catch {
|
|
200
|
+
/* client may have dropped */
|
|
176
201
|
}
|
|
177
202
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
/* client may have dropped */
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}, 15_000);
|
|
203
|
+
}, 15_000);
|
|
204
|
+
}
|
|
205
|
+
for (const cb of this.#onConnected) cb();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Try to bind the loopback WS server to `port`. Returns false on EADDRINUSE
|
|
209
|
+
* (so the caller can try the next candidate); rethrows any other error. */
|
|
210
|
+
listen(port: number, opts?: { skipOriginCheck?: boolean }): boolean {
|
|
211
|
+
try {
|
|
212
|
+
this.#server = Bun.serve({
|
|
213
|
+
port,
|
|
214
|
+
hostname: "127.0.0.1",
|
|
215
|
+
fetch: (req, server) => {
|
|
216
|
+
if (!opts?.skipOriginCheck) {
|
|
217
|
+
const origin = req.headers.get("origin") ?? "";
|
|
218
|
+
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
219
|
+
if (origin !== `chrome-extension://${EXTENSION_ID}`) {
|
|
220
|
+
return new Response("Forbidden", { status: 403 });
|
|
221
|
+
}
|
|
201
222
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
message: (ws, message) => {
|
|
205
|
-
this.#handleMessage(ws, message);
|
|
223
|
+
if (server.upgrade(req)) return undefined;
|
|
224
|
+
return new Response("xcsh bridge: WebSocket only", { status: 426 });
|
|
206
225
|
},
|
|
207
|
-
|
|
208
|
-
this.#
|
|
226
|
+
websocket: {
|
|
227
|
+
open: ws => this.#onOpen(ws),
|
|
228
|
+
message: (ws, message) => this.#handleMessage(ws, message),
|
|
229
|
+
close: ws => this.#onClose(ws),
|
|
209
230
|
},
|
|
210
|
-
}
|
|
211
|
-
|
|
231
|
+
});
|
|
232
|
+
return true;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
if (e instanceof Error && /EADDRINUSE|address already in use|in use/i.test(e.message)) return false;
|
|
235
|
+
throw e;
|
|
236
|
+
}
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
#handleMessage(ws: ServerWebSocket<undefined>, message: string | Buffer): void {
|
|
@@ -228,14 +253,17 @@ export class BridgeServer {
|
|
|
228
253
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
229
254
|
} else if (msg.type === "hello") {
|
|
230
255
|
// Identity handshake: tell the extension which tenant this process serves.
|
|
231
|
-
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null };
|
|
256
|
+
const info = this.#sessionInfo?.() ?? { tenant: null, env: null, apiUrl: null, contextBound: false };
|
|
257
|
+
const { EXTENSION_CONTRACT_VERSION } = require("./capabilities.generated");
|
|
232
258
|
ws.send(
|
|
233
259
|
JSON.stringify({
|
|
234
260
|
type: "hello_ack",
|
|
235
261
|
sessionId: this.#sessionId,
|
|
262
|
+
contractVersion: EXTENSION_CONTRACT_VERSION,
|
|
236
263
|
tenant: info.tenant,
|
|
237
264
|
env: info.env,
|
|
238
265
|
apiUrl: info.apiUrl,
|
|
266
|
+
contextBound: info.contextBound,
|
|
239
267
|
pid: process.pid,
|
|
240
268
|
}),
|
|
241
269
|
);
|
|
@@ -293,12 +321,23 @@ export class BridgeServer {
|
|
|
293
321
|
}
|
|
294
322
|
|
|
295
323
|
/**
|
|
296
|
-
* Start the {@link BridgeServer} on the resolved loopback port
|
|
297
|
-
*
|
|
324
|
+
* Start the {@link BridgeServer} on the resolved loopback port. If a port is
|
|
325
|
+
* forced (explicit arg or `XCSH_BRIDGE_PORT`) it must be free — throws loudly
|
|
326
|
+
* if taken. Otherwise, auto-selects the lowest free port in the discovery range
|
|
327
|
+
* ({@link PORT_RANGE_START}–{@link PORT_RANGE_END}). The WebSocket transport
|
|
298
328
|
* needs no filesystem setup — Chrome connects directly to `ws://127.0.0.1:<port>`.
|
|
299
329
|
*/
|
|
300
330
|
export async function startBridgeServer(port?: number, opts?: { skipOriginCheck?: boolean }): Promise<BridgeServer> {
|
|
301
331
|
const server = new BridgeServer();
|
|
302
|
-
|
|
303
|
-
|
|
332
|
+
const forced = resolveForcedPort(port);
|
|
333
|
+
if (forced !== null) {
|
|
334
|
+
if (!server.listen(forced, opts)) {
|
|
335
|
+
throw new Error(`XCSH_BRIDGE_PORT ${forced} is already in use — free it or pick another port`);
|
|
336
|
+
}
|
|
337
|
+
return server;
|
|
338
|
+
}
|
|
339
|
+
for (const candidate of portCandidates()) {
|
|
340
|
+
if (server.listen(candidate, opts)) return server;
|
|
341
|
+
}
|
|
342
|
+
throw new Error(`no free xcsh bridge port in ${PORT_RANGE_START}-${PORT_RANGE_END} — is another app on the range?`);
|
|
304
343
|
}
|
|
@@ -501,7 +501,6 @@ export class ExtensionPageActions implements PageActions {
|
|
|
501
501
|
throw new Error(`waitFor "${selector}" timed out after ${ms}ms`);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
// biome-ignore lint/suspicious/useAwait: signature is async (PageActions contract)
|
|
505
504
|
async screenshot(_file: string): Promise<void> {
|
|
506
505
|
// Intentionally a no-op for the extension provider. CDP captureScreenshot
|
|
507
506
|
// transiently FREEZES the MV3 service worker; in observable mode the runner
|
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
|
-
import {
|
|
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 [
|
|
@@ -43,14 +89,28 @@ export async function runChromeCommand(action: ChromeAction, settings: Settings)
|
|
|
43
89
|
if (action === "status") return renderStatus(await provider.status());
|
|
44
90
|
if (action === "setup") {
|
|
45
91
|
// The extension connects directly over a loopback WebSocket — no native-messaging
|
|
46
|
-
// host manifest to install.
|
|
47
|
-
|
|
92
|
+
// host manifest to install. A forced port is reported exactly; otherwise xcsh
|
|
93
|
+
// auto-selects the lowest free port in the discovery range at launch.
|
|
94
|
+
const forced = resolveForcedPort();
|
|
95
|
+
const where =
|
|
96
|
+
forced !== null
|
|
97
|
+
? `ws://127.0.0.1:${forced} (forced via XCSH_BRIDGE_PORT)`
|
|
98
|
+
: `the lowest free port in ${PORT_RANGE_START}-${PORT_RANGE_END} (printed in the xcsh startup banner)`;
|
|
48
99
|
return (
|
|
49
|
-
`The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ` +
|
|
50
|
-
`
|
|
51
|
-
`Install/keep the xcsh Chrome extension from the Web Store
|
|
100
|
+
`The xcsh Chrome extension connects directly to xcsh over a loopback WebSocket on ${where}.\n` +
|
|
101
|
+
`The extension scans that range and links each tenant's xcsh automatically.\n` +
|
|
102
|
+
`Install/keep the xcsh Chrome extension from the Web Store:\n ${WEB_STORE_URL}`
|
|
52
103
|
);
|
|
53
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
|
+
}
|
|
54
114
|
// relaunch: self-consented rung 3 — force allowRelaunch regardless of the setting.
|
|
55
115
|
const { mode } = await acquirePage({ settings, allowRelaunch: true });
|
|
56
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
|
+
}
|