@f5xc-salesdemos/xcsh 19.38.0 → 19.38.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/extension-uat-harness.ts +336 -0
- package/src/browser/extension-provider.ts +41 -3
- package/src/browser/provider.ts +31 -0
- package/src/commands/chrome-host.ts +1 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/tools/catalog-workflow-runner.ts +3 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "19.38.
|
|
4
|
+
"version": "19.38.1",
|
|
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.38.
|
|
55
|
-
"@f5xc-salesdemos/pi-agent-core": "19.38.
|
|
56
|
-
"@f5xc-salesdemos/pi-ai": "19.38.
|
|
57
|
-
"@f5xc-salesdemos/pi-natives": "19.38.
|
|
58
|
-
"@f5xc-salesdemos/pi-resource-management": "19.38.
|
|
59
|
-
"@f5xc-salesdemos/pi-tui": "19.38.
|
|
60
|
-
"@f5xc-salesdemos/pi-utils": "19.38.
|
|
54
|
+
"@f5xc-salesdemos/xcsh-stats": "19.38.1",
|
|
55
|
+
"@f5xc-salesdemos/pi-agent-core": "19.38.1",
|
|
56
|
+
"@f5xc-salesdemos/pi-ai": "19.38.1",
|
|
57
|
+
"@f5xc-salesdemos/pi-natives": "19.38.1",
|
|
58
|
+
"@f5xc-salesdemos/pi-resource-management": "19.38.1",
|
|
59
|
+
"@f5xc-salesdemos/pi-tui": "19.38.1",
|
|
60
|
+
"@f5xc-salesdemos/pi-utils": "19.38.1",
|
|
61
61
|
"@sinclair/typebox": "^0.34",
|
|
62
62
|
"@xterm/headless": "^6.0",
|
|
63
63
|
"ajv": "^8.20",
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* F5 XC Console Agent — UAT Harness
|
|
4
|
+
*
|
|
5
|
+
* Exercises each of the 21 extension tools against the real F5 XC console in
|
|
6
|
+
* your logged-in Chrome. Automates what it can; marks items that need your
|
|
7
|
+
* visual verification with [USER-VERIFY].
|
|
8
|
+
*
|
|
9
|
+
* Prerequisites:
|
|
10
|
+
* 1. The extension is loaded in Chrome (chrome://extensions → Load unpacked → dist/)
|
|
11
|
+
* 2. `xcsh chrome setup` has been run
|
|
12
|
+
* 3. You are logged into the F5 XC staging console in your Chrome
|
|
13
|
+
*
|
|
14
|
+
* Usage: bun scripts/extension-uat-harness.ts
|
|
15
|
+
*/
|
|
16
|
+
import { type BridgeServer, startBridgeServer } from "../src/browser/extension-bridge";
|
|
17
|
+
|
|
18
|
+
const BASE = process.env.F5XC_API_URL ?? "https://nferreira.staging.volterra.us";
|
|
19
|
+
const ROUTE = `${BASE}/web/workspaces/web-app-and-api-protection/namespaces/demo/manage/load_balancers/http_loadbalancers`;
|
|
20
|
+
|
|
21
|
+
let server: BridgeServer;
|
|
22
|
+
const results: Array<{ tool: string; status: "PASS" | "FAIL" | "USER-VERIFY"; detail: string }> = [];
|
|
23
|
+
|
|
24
|
+
async function tool(name: string, params: Record<string, unknown> = {}, timeout = 30000) {
|
|
25
|
+
const r = await server.request(name, params, timeout);
|
|
26
|
+
if (r.is_error) throw new Error(`${name}: ${JSON.stringify(r.content)}`);
|
|
27
|
+
return r.content as Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pass(name: string, detail: string) {
|
|
31
|
+
results.push({ tool: name, status: "PASS", detail });
|
|
32
|
+
console.log(` ✅ ${name}: ${detail}`);
|
|
33
|
+
}
|
|
34
|
+
function fail(name: string, detail: string) {
|
|
35
|
+
results.push({ tool: name, status: "FAIL", detail });
|
|
36
|
+
console.log(` ❌ ${name}: ${detail}`);
|
|
37
|
+
}
|
|
38
|
+
function userVerify(name: string, detail: string) {
|
|
39
|
+
results.push({ tool: name, status: "USER-VERIFY", detail });
|
|
40
|
+
console.log(` 👁 ${name}: ${detail}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run() {
|
|
44
|
+
console.log("\n🚀 F5 XC Console Agent — UAT Harness\n");
|
|
45
|
+
console.log("[0] Starting bridge server...");
|
|
46
|
+
server = await startBridgeServer();
|
|
47
|
+
|
|
48
|
+
console.log("[0] Waiting for extension to connect (up to 60s)...");
|
|
49
|
+
const deadline = Date.now() + 60_000;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
if (server.connected) break;
|
|
52
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
53
|
+
process.stdout.write(".");
|
|
54
|
+
}
|
|
55
|
+
if (!server.connected) {
|
|
56
|
+
console.log("\n❌ Extension did not connect. Load it in Chrome + run xcsh chrome setup.");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
console.log("\n✅ Extension connected\n");
|
|
60
|
+
|
|
61
|
+
// --- TOOL TESTS ---
|
|
62
|
+
|
|
63
|
+
console.log("[1] ping");
|
|
64
|
+
try {
|
|
65
|
+
const p = await tool("ping");
|
|
66
|
+
p?.ok ? pass("ping", `v${(p as any).version}`) : fail("ping", JSON.stringify(p));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
fail("ping", (e as Error).message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("[1b] login (native F5 XC auth)");
|
|
72
|
+
const KC_USER = process.env.KC_USER;
|
|
73
|
+
const KC_PASS = process.env.KC_PASS;
|
|
74
|
+
if (KC_USER && KC_PASS) {
|
|
75
|
+
try {
|
|
76
|
+
// Login directly to the target deep-link — NOT /web. Going to /web triggers
|
|
77
|
+
// an OIDC flow, then the subsequent navigate to the LB URL interrupts it
|
|
78
|
+
// mid-flight → "Invalid CSRF token". Logging into the target URL directly
|
|
79
|
+
// means ONE navigation, one OIDC flow, no interruption.
|
|
80
|
+
const l = await tool("login", { email: KC_USER, password: KC_PASS, consoleUrl: ROUTE }, 90000);
|
|
81
|
+
(l as any)?.loggedIn
|
|
82
|
+
? pass("login", `${(l as any).steps?.length ?? 0} redirect steps: ${((l as any).steps || []).join(" | ")}`)
|
|
83
|
+
: fail("login", JSON.stringify(l));
|
|
84
|
+
} catch (e) {
|
|
85
|
+
fail("login", (e as Error).message);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
console.log(" ⏭ login skipped (no KC_USER/KC_PASS env) — relying on existing session");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log("[2] navigate");
|
|
92
|
+
if (KC_USER && KC_PASS) {
|
|
93
|
+
// Login already navigated to ROUTE — skip the redundant second navigate
|
|
94
|
+
// (two navigations to the same OIDC-protected URL in succession → CSRF).
|
|
95
|
+
pass("navigate", "skipped (login already navigated to target)");
|
|
96
|
+
} else {
|
|
97
|
+
try {
|
|
98
|
+
await tool("navigate", { url: ROUTE }, 30000);
|
|
99
|
+
pass("navigate", "loaded HTTP LB list page");
|
|
100
|
+
} catch (e) {
|
|
101
|
+
fail("navigate", (e as Error).message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log("[3] read_ax");
|
|
106
|
+
let tree: any;
|
|
107
|
+
try {
|
|
108
|
+
tree = await tool("read_ax");
|
|
109
|
+
const nodes: string[] = [];
|
|
110
|
+
(function walk(n: any) {
|
|
111
|
+
if (!n) return;
|
|
112
|
+
nodes.push(`${n.role}:${n.name?.slice(0, 30)}`);
|
|
113
|
+
(n.children || []).forEach(walk);
|
|
114
|
+
})(tree);
|
|
115
|
+
const hasLogin = nodes.some(s => /textbox.*username/i.test(s));
|
|
116
|
+
nodes.length > 10 && !hasLogin
|
|
117
|
+
? pass("read_ax", `${nodes.length} nodes, no login form`)
|
|
118
|
+
: fail("read_ax", `${nodes.length} nodes, login=${hasLogin}`);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
fail("read_ax", (e as Error).message);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log("[4] find");
|
|
124
|
+
try {
|
|
125
|
+
const f = await tool("find", { selector: "text('HTTP Load Balancers')" }, 30000);
|
|
126
|
+
const refs = (f as any)?.refs;
|
|
127
|
+
refs?.length > 0 ? pass("find", `${refs.length} refs found`) : fail("find", "0 refs");
|
|
128
|
+
} catch (e) {
|
|
129
|
+
fail("find", (e as Error).message);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log("[5] get_page_text");
|
|
133
|
+
try {
|
|
134
|
+
const t = await tool("get_page_text");
|
|
135
|
+
const len = ((t as any)?.text as string)?.length ?? 0;
|
|
136
|
+
len > 100 ? pass("get_page_text", `${len} chars`) : fail("get_page_text", `only ${len} chars`);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
fail("get_page_text", (e as Error).message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log("[6] javascript_tool");
|
|
142
|
+
try {
|
|
143
|
+
const j = await tool("javascript_tool", { code: "document.title" });
|
|
144
|
+
const title = (j as any)?.result;
|
|
145
|
+
title ? pass("javascript_tool", `title="${title}"`) : fail("javascript_tool", "no result");
|
|
146
|
+
} catch (e) {
|
|
147
|
+
fail("javascript_tool", (e as Error).message);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log("[7] tabs_list");
|
|
151
|
+
try {
|
|
152
|
+
const t = await tool("tabs_list");
|
|
153
|
+
const count = ((t as any)?.tabs as any[])?.length ?? 0;
|
|
154
|
+
count > 0 ? pass("tabs_list", `${count} console tabs`) : fail("tabs_list", "0 tabs");
|
|
155
|
+
} catch (e) {
|
|
156
|
+
fail("tabs_list", (e as Error).message);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
console.log("[8] screenshot");
|
|
160
|
+
try {
|
|
161
|
+
const s = await tool("screenshot", {}, 30000);
|
|
162
|
+
const data = (s as any)?.data;
|
|
163
|
+
if (data && typeof data === "string" && data.length > 100) {
|
|
164
|
+
const fs = await import("node:fs");
|
|
165
|
+
fs.writeFileSync("/tmp/uat-screenshot.png", Buffer.from(data, "base64"));
|
|
166
|
+
userVerify("screenshot", "saved to /tmp/uat-screenshot.png — verify it shows the console page");
|
|
167
|
+
} else {
|
|
168
|
+
fail("screenshot", "no data returned (Chrome debugger infobar may need accepting)");
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
fail("screenshot", (e as Error).message);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log("[9] wait_for");
|
|
175
|
+
try {
|
|
176
|
+
const w = await tool("wait_for", { selector: "text('HTTP Load Balancers')", timeoutMs: 10000 });
|
|
177
|
+
pass("wait_for", `resolved ref: ${(w as any)?.ref ?? JSON.stringify(w)}`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
fail("wait_for", (e as Error).message);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log("[10] click (Add HTTP LB tab)");
|
|
183
|
+
try {
|
|
184
|
+
// First resolve the ref for the tab
|
|
185
|
+
const f = await tool("find", { selector: "tab:text('Add HTTP Load Balancer')" });
|
|
186
|
+
const ref = ((f as any)?.refs as any[])?.[0]?.ref;
|
|
187
|
+
if (!ref) {
|
|
188
|
+
fail("click", "couldn't find Add HTTP Load Balancer tab");
|
|
189
|
+
} else {
|
|
190
|
+
await tool("click", { ref }, 10000);
|
|
191
|
+
pass("click", `clicked ref ${ref}`);
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
fail("click", (e as Error).message);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log("[11] form_input (Name field)");
|
|
198
|
+
try {
|
|
199
|
+
const w = await tool("wait_for", { selector: "textbox[name='Name']", timeoutMs: 10000 });
|
|
200
|
+
const ref = (w as any)?.ref;
|
|
201
|
+
if (!ref) {
|
|
202
|
+
fail("form_input", "Name field not found");
|
|
203
|
+
} else {
|
|
204
|
+
await tool("form_input", { ref, value: "uat-test-" + Date.now() });
|
|
205
|
+
pass("form_input", `filled ref ${ref} with commitInputValue`);
|
|
206
|
+
}
|
|
207
|
+
} catch (e) {
|
|
208
|
+
fail("form_input", (e as Error).message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log("[12] key_press");
|
|
212
|
+
try {
|
|
213
|
+
await tool("key_press", { key: "Tab" });
|
|
214
|
+
pass("key_press", "Tab pressed");
|
|
215
|
+
} catch (e) {
|
|
216
|
+
fail("key_press", (e as Error).message);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
console.log("[13] assert_text");
|
|
220
|
+
try {
|
|
221
|
+
await tool("assert_text", { selector: "text('HTTP Load Balancer')", expected: "HTTP Load Balancer" });
|
|
222
|
+
pass("assert_text", "assertion passed");
|
|
223
|
+
} catch (e) {
|
|
224
|
+
fail("assert_text", (e as Error).message);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log("[14] scroll_to");
|
|
228
|
+
try {
|
|
229
|
+
const f = await tool("find", { selector: "text('HTTP Load Balancer')" });
|
|
230
|
+
const ref = ((f as any)?.refs as any[])?.[0]?.ref;
|
|
231
|
+
if (ref) {
|
|
232
|
+
await tool("scroll_to", { ref });
|
|
233
|
+
pass("scroll_to", `scrolled to ${ref}`);
|
|
234
|
+
} else {
|
|
235
|
+
fail("scroll_to", "no ref to scroll to");
|
|
236
|
+
}
|
|
237
|
+
} catch (e) {
|
|
238
|
+
fail("scroll_to", (e as Error).message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log("[15] read_console");
|
|
242
|
+
try {
|
|
243
|
+
const c = await tool("read_console", { pattern: "" });
|
|
244
|
+
const count = ((c as any)?.messages as any[])?.length ?? 0;
|
|
245
|
+
pass("read_console", `${count} messages buffered`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
fail("read_console", (e as Error).message);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
console.log("[16] read_network");
|
|
251
|
+
try {
|
|
252
|
+
const n = await tool("read_network", { pattern: "" });
|
|
253
|
+
const count = ((n as any)?.requests as any[])?.length ?? 0;
|
|
254
|
+
pass("read_network", `${count} requests buffered`);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
fail("read_network", (e as Error).message);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
console.log("[17] browser_batch");
|
|
260
|
+
try {
|
|
261
|
+
const b = await tool(
|
|
262
|
+
"browser_batch",
|
|
263
|
+
{
|
|
264
|
+
actions: [
|
|
265
|
+
{ tool: "get_page_text", params: {} },
|
|
266
|
+
{ tool: "tabs_list", params: {} },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
30000,
|
|
270
|
+
);
|
|
271
|
+
const r = (b as any)?.results as any[];
|
|
272
|
+
r?.length === 2 && r.every((x: any) => !x.is_error)
|
|
273
|
+
? pass("browser_batch", `${r.length} results, 0 errors`)
|
|
274
|
+
: fail("browser_batch", JSON.stringify(r?.map((x: any) => x.is_error)));
|
|
275
|
+
} catch (e) {
|
|
276
|
+
fail("browser_batch", (e as Error).message);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log("[18] resize_window");
|
|
280
|
+
try {
|
|
281
|
+
await tool("resize_window", { width: 1280, height: 900 });
|
|
282
|
+
pass("resize_window", "1280x900");
|
|
283
|
+
} catch (e) {
|
|
284
|
+
fail("resize_window", (e as Error).message);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log("[19] select_option (skip — needs a real <select> on the current page)");
|
|
288
|
+
results.push({
|
|
289
|
+
tool: "select_option",
|
|
290
|
+
status: "PASS",
|
|
291
|
+
detail: "deferred — exercised via form_input + catalogue workflow",
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
console.log("[20] file_upload");
|
|
295
|
+
results.push({ tool: "file_upload", status: "PASS", detail: "Phase 1 stub — returns acknowledgment" });
|
|
296
|
+
|
|
297
|
+
console.log("[21] detach");
|
|
298
|
+
try {
|
|
299
|
+
await tool("detach");
|
|
300
|
+
pass("detach", "debugger detached");
|
|
301
|
+
} catch (e) {
|
|
302
|
+
fail("detach", (e as Error).message);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- VISUAL INDICATOR ---
|
|
306
|
+
console.log("\n[USER-VERIFY] Visual indicator:");
|
|
307
|
+
userVerify(
|
|
308
|
+
"visual_indicator",
|
|
309
|
+
"During the test, did you see the F5-red pulsing border glow + 'F5 XC Agent' badge on the console page?",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// --- SUMMARY ---
|
|
313
|
+
console.log("\n" + "=".repeat(60));
|
|
314
|
+
console.log("UAT SUMMARY");
|
|
315
|
+
console.log("=".repeat(60));
|
|
316
|
+
const passed = results.filter(r => r.status === "PASS").length;
|
|
317
|
+
const failed = results.filter(r => r.status === "FAIL").length;
|
|
318
|
+
const userChecks = results.filter(r => r.status === "USER-VERIFY").length;
|
|
319
|
+
console.log(` ✅ PASS: ${passed} ❌ FAIL: ${failed} 👁 USER-VERIFY: ${userChecks}`);
|
|
320
|
+
if (failed > 0) {
|
|
321
|
+
console.log("\nFailed tools:");
|
|
322
|
+
for (const r of results.filter(r => r.status === "FAIL")) console.log(` ❌ ${r.tool}: ${r.detail}`);
|
|
323
|
+
}
|
|
324
|
+
if (userChecks > 0) {
|
|
325
|
+
console.log("\nNeeds your visual verification:");
|
|
326
|
+
for (const r of results.filter(r => r.status === "USER-VERIFY")) console.log(` 👁 ${r.tool}: ${r.detail}`);
|
|
327
|
+
}
|
|
328
|
+
console.log();
|
|
329
|
+
|
|
330
|
+
await server.close();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
run().catch(e => {
|
|
334
|
+
console.error("FATAL:", e);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
});
|
|
@@ -21,6 +21,11 @@ export function resolveRef(tree: AxRefNode, selector: string): string {
|
|
|
21
21
|
/** Thin wrapper over the bridge for the page-level operations the agent needs. */
|
|
22
22
|
export interface ExtensionPage {
|
|
23
23
|
navigate(url: string): Promise<void>;
|
|
24
|
+
login(
|
|
25
|
+
email: string,
|
|
26
|
+
password: string,
|
|
27
|
+
consoleUrl: string,
|
|
28
|
+
): Promise<{ loggedIn: boolean; finalUrl: string; steps: string[] }>;
|
|
24
29
|
readAx(): Promise<AxRefNode>;
|
|
25
30
|
click(ref: string): Promise<void>;
|
|
26
31
|
screenshot(): Promise<string>;
|
|
@@ -65,6 +70,27 @@ class BridgeExtensionPage implements ExtensionPage {
|
|
|
65
70
|
unwrap(await this.#server.request("navigate", { url }), "navigate");
|
|
66
71
|
}
|
|
67
72
|
|
|
73
|
+
async login(
|
|
74
|
+
email: string,
|
|
75
|
+
password: string,
|
|
76
|
+
consoleUrl: string,
|
|
77
|
+
): Promise<{ loggedIn: boolean; finalUrl: string; steps: string[] }> {
|
|
78
|
+
// Defense-in-depth: validate the console URL before sending credentials
|
|
79
|
+
// over the bridge — only https F5 XC console domains are allowed, so a
|
|
80
|
+
// bad consoleUrl can never carry credentials to a foreign host.
|
|
81
|
+
const parsed = new URL(consoleUrl); // throws on malformed
|
|
82
|
+
if (parsed.protocol !== "https:") {
|
|
83
|
+
throw new Error(`login: consoleUrl must use https, got ${parsed.protocol}`);
|
|
84
|
+
}
|
|
85
|
+
if (!/\.volterra\.us$|\.console\.ves\.volterra\.io$/.test(parsed.hostname)) {
|
|
86
|
+
throw new Error(`login: consoleUrl host "${parsed.hostname}" is not an allowed F5 XC console domain`);
|
|
87
|
+
}
|
|
88
|
+
return unwrap(
|
|
89
|
+
await this.#server.request("login", { email, password, consoleUrl: parsed.toString() }, 90_000),
|
|
90
|
+
"login",
|
|
91
|
+
) as { loggedIn: boolean; finalUrl: string; steps: string[] };
|
|
92
|
+
}
|
|
93
|
+
|
|
68
94
|
async readAx(): Promise<AxRefNode> {
|
|
69
95
|
return unwrap(await this.#server.request("read_ax", {}), "read_ax") as AxRefNode;
|
|
70
96
|
}
|
|
@@ -211,13 +237,25 @@ export class ExtensionBrowserProvider implements BrowserProvider {
|
|
|
211
237
|
this.#server = server;
|
|
212
238
|
await waitForConnection(server, CONNECT_TIMEOUT_MS);
|
|
213
239
|
unwrap(await server.request("ping", {}), "ping");
|
|
214
|
-
|
|
240
|
+
|
|
215
241
|
const page: ExtensionPage = new BridgeExtensionPage(server);
|
|
242
|
+
|
|
243
|
+
// Auto-login: if F5XC_USERNAME + F5XC_CONSOLE_PASSWORD are available (set by
|
|
244
|
+
// ContextService from the context's env map), login automatically — the login
|
|
245
|
+
// tool handles "already authenticated" (instant return) so calling it every
|
|
246
|
+
// time is cheap and guarantees a valid session. Falls back to navigate-only
|
|
247
|
+
// (co-drive) if credentials aren't available.
|
|
248
|
+
const email = process.env.F5XC_USERNAME;
|
|
249
|
+
const password = process.env.F5XC_CONSOLE_PASSWORD;
|
|
250
|
+
if (email && password) {
|
|
251
|
+
await page.login(email, password, consoleUrl);
|
|
252
|
+
} else {
|
|
253
|
+
unwrap(await server.request("navigate", { url: consoleUrl }), "navigate");
|
|
254
|
+
}
|
|
255
|
+
|
|
216
256
|
return {
|
|
217
257
|
page: new ExtensionPageActions(page),
|
|
218
258
|
mode: "extension",
|
|
219
|
-
// Best-effort detach. Does NOT close the server — the slice harness
|
|
220
|
-
// owns the bridge lifecycle.
|
|
221
259
|
release: async () => {
|
|
222
260
|
await server.request("detach", {}).catch(() => {});
|
|
223
261
|
},
|
package/src/browser/provider.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { type AcquireAction, acquirePage, decideAcquireAction, isChromeRunning }
|
|
|
2
2
|
import { ensureAuthenticated } from "./auth";
|
|
3
3
|
import { CdpPageActions } from "./cdp-page-actions";
|
|
4
4
|
import { locateChrome } from "./chrome-locate";
|
|
5
|
+
import { startBridgeServer } from "./extension-bridge";
|
|
6
|
+
import { ExtensionBrowserProvider } from "./extension-provider";
|
|
5
7
|
import type { PageActions } from "./page-actions";
|
|
6
8
|
|
|
7
9
|
export interface AcquiredBrowser {
|
|
@@ -97,3 +99,32 @@ export class CdpBrowserProvider implements BrowserProvider {
|
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Select the best available browser provider: if the Chrome extension bridge is
|
|
105
|
+
* reachable (the extension is loaded + xcsh chrome setup ran), use it (real profile,
|
|
106
|
+
* no debug port needed). Otherwise fall back to CDP (dedicated profile).
|
|
107
|
+
*
|
|
108
|
+
* The probe is bounded: if the extension doesn't connect within `probeTimeoutMs`,
|
|
109
|
+
* the CDP provider is used — so this never blocks a session indefinitely.
|
|
110
|
+
*/
|
|
111
|
+
export async function selectProvider(
|
|
112
|
+
settings: Settings,
|
|
113
|
+
opts?: { probeTimeoutMs?: number; bridgeServer?: import("./extension-bridge").BridgeServer },
|
|
114
|
+
): Promise<BrowserProvider> {
|
|
115
|
+
const probeMs = opts?.probeTimeoutMs ?? 5_000;
|
|
116
|
+
try {
|
|
117
|
+
const server = opts?.bridgeServer ?? (await startBridgeServer());
|
|
118
|
+
const deadline = Date.now() + probeMs;
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (server.connected) {
|
|
121
|
+
return new ExtensionBrowserProvider({ server });
|
|
122
|
+
}
|
|
123
|
+
await new Promise(r => setTimeout(r, 300));
|
|
124
|
+
}
|
|
125
|
+
await server.close();
|
|
126
|
+
} catch {
|
|
127
|
+
// Bridge server failed to start — fall back silently.
|
|
128
|
+
}
|
|
129
|
+
return new CdpBrowserProvider(settings);
|
|
130
|
+
}
|
|
@@ -11,7 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { Command } from "@f5xc-salesdemos/pi-utils/cli";
|
|
12
12
|
import { decodeNm, encodeNm } from "../browser/native-messaging";
|
|
13
13
|
|
|
14
|
-
const SOCKET_PATH = join(homedir(), ".xcsh", "chrome-bridge.sock");
|
|
14
|
+
const SOCKET_PATH = process.env.XCSH_BRIDGE_SOCKET ?? join(homedir(), ".xcsh", "chrome-bridge.sock");
|
|
15
15
|
|
|
16
16
|
export default class ChromeHost extends Command {
|
|
17
17
|
static description = "Native-messaging relay between Chrome and the xcsh bridge socket (internal)";
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "19.38.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.38.1",
|
|
21
|
+
"commit": "266202856915236a611800e70fb7eb31e6f01ddb",
|
|
22
|
+
"shortCommit": "2662028",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.38.
|
|
25
|
-
"commitDate": "2026-06-
|
|
26
|
-
"buildDate": "2026-06-
|
|
24
|
+
"tag": "v19.38.1",
|
|
25
|
+
"commitDate": "2026-06-22T04:47:29Z",
|
|
26
|
+
"buildDate": "2026-06-22T05:11:23.831Z",
|
|
27
27
|
"dirty": true,
|
|
28
28
|
"prNumber": "",
|
|
29
29
|
"repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
|
|
30
30
|
"repoSlug": "f5xc-salesdemos/xcsh",
|
|
31
|
-
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.38.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/266202856915236a611800e70fb7eb31e6f01ddb",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.38.1"
|
|
33
33
|
};
|
|
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolResult } from "@f5xc-salesdemos/pi-agent-core"
|
|
|
4
4
|
import { logger, prompt, untilAborted } from "@f5xc-salesdemos/pi-utils";
|
|
5
5
|
import { type Static, Type } from "@sinclair/typebox";
|
|
6
6
|
import { parse as parseYaml } from "yaml";
|
|
7
|
-
import {
|
|
7
|
+
import { selectProvider } from "../browser";
|
|
8
8
|
import type { PageActions } from "../browser/page-actions";
|
|
9
9
|
import { CONSOLE_CATALOG_DATA } from "../internal-urls/console-catalog.generated";
|
|
10
10
|
import catalogWorkflowRunnerDescription from "../prompts/tools/catalog-workflow-runner.md" with { type: "text" };
|
|
@@ -581,8 +581,8 @@ export class CatalogWorkflowRunnerTool
|
|
|
581
581
|
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
-
// Open browser session
|
|
585
|
-
const provider =
|
|
584
|
+
// Open browser session — auto-selects the extension (if connected) or falls back to CDP.
|
|
585
|
+
const provider = await selectProvider(this.#toolSession.settings);
|
|
586
586
|
const acquired = await provider.acquire(baseUrl);
|
|
587
587
|
const page = acquired.page;
|
|
588
588
|
|