@f5xc-salesdemos/xcsh 19.35.1 → 19.36.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.35.1",
4
+ "version": "19.36.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.35.1",
55
- "@f5xc-salesdemos/pi-agent-core": "19.35.1",
56
- "@f5xc-salesdemos/pi-ai": "19.35.1",
57
- "@f5xc-salesdemos/pi-natives": "19.35.1",
58
- "@f5xc-salesdemos/pi-resource-management": "19.35.1",
59
- "@f5xc-salesdemos/pi-tui": "19.35.1",
60
- "@f5xc-salesdemos/pi-utils": "19.35.1",
54
+ "@f5xc-salesdemos/xcsh-stats": "19.36.0",
55
+ "@f5xc-salesdemos/pi-agent-core": "19.36.0",
56
+ "@f5xc-salesdemos/pi-ai": "19.36.0",
57
+ "@f5xc-salesdemos/pi-natives": "19.36.0",
58
+ "@f5xc-salesdemos/pi-resource-management": "19.36.0",
59
+ "@f5xc-salesdemos/pi-tui": "19.36.0",
60
+ "@f5xc-salesdemos/pi-utils": "19.36.0",
61
61
  "@sinclair/typebox": "^0.34",
62
62
  "@xterm/headless": "^6.0",
63
63
  "ajv": "^8.20",
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bun
2
+ // One-shot capture of the F5/Keycloak login-wall DOM fixture used by the
3
+ // auth-preflight detection predicates:
4
+ // test/browser/fixtures/xc-login-wall.html (logged-OUT login page)
5
+ //
6
+ // Uses a throwaway profile so the first navigation is unauthenticated. No creds
7
+ // needed (we only capture the login page). Run manually; delete the profile after.
8
+ //
9
+ // The authenticated-console fixture (test/browser/fixtures/xc-console-authed.html)
10
+ // is sourced from a real logged-in console capture rather than scripted login:
11
+ // a fresh Chrome profile hits a Keycloak "login-actions" interstitial that is not
12
+ // worth automating just to produce a fixture.
13
+ import puppeteer from "puppeteer";
14
+
15
+ const CHROME = process.env.CHROME_PATH;
16
+ const BASE = process.env.F5XC_API_URL ?? "https://nferreira.staging.volterra.us";
17
+ const PROFILE = "/tmp/xc-login-capture-profile";
18
+ const LOGIN_OUT = "test/browser/fixtures/xc-login-wall.html";
19
+
20
+ const browser = await puppeteer.launch({ headless: true, executablePath: CHROME, userDataDir: PROFILE });
21
+ try {
22
+ const page = (await browser.pages())[0] ?? (await browser.newPage());
23
+ await page.goto(BASE, { waitUntil: "networkidle2", timeout: 45000 }).catch(() => {});
24
+ await new Promise(r => setTimeout(r, 2500));
25
+ const loginHtml = await page.content();
26
+ await Bun.write(LOGIN_OUT, loginHtml);
27
+ console.log(`wrote ${LOGIN_OUT} (${loginHtml.length} bytes) url=${page.url().slice(0, 80)}`);
28
+ } finally {
29
+ await browser.close().catch(() => {});
30
+ }
@@ -0,0 +1,154 @@
1
+ import * as os from "node:os";
2
+ import * as path from "node:path";
3
+ import type { Browser, Page } from "puppeteer";
4
+ import { assertLoopbackBrowserUrl, pickCoDrivePage, resolveBrowserConnectUrl } from "../tools/browser";
5
+ import { locateChrome } from "./chrome-locate";
6
+
7
+ export type AcquireMode = "attached" | "launched-default" | "launched-dedicated";
8
+
9
+ const DEFAULT_DEBUG_PORT = 9222;
10
+
11
+ export function dedicatedProfileDir(home: string = os.homedir()): string {
12
+ return path.join(home, ".xcsh", "chrome-profile");
13
+ }
14
+
15
+ export function defaultProfileDir(opts?: {
16
+ platform?: NodeJS.Platform;
17
+ env?: NodeJS.ProcessEnv;
18
+ home?: string;
19
+ }): string | null {
20
+ const platform = opts?.platform ?? process.platform;
21
+ const env = opts?.env ?? process.env;
22
+ const home = opts?.home ?? os.homedir();
23
+ switch (platform) {
24
+ case "darwin":
25
+ return path.join(home, "Library", "Application Support", "Google", "Chrome");
26
+ case "linux":
27
+ return path.join(home, ".config", "google-chrome");
28
+ case "win32": {
29
+ const localAppData = env.LOCALAPPDATA;
30
+ if (!localAppData) return null;
31
+ return path.join(localAppData, "Google", "Chrome", "User Data");
32
+ }
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+
38
+ async function withLaunchTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ const timeout = new Promise<never>((_resolve, reject) => {
41
+ timer = setTimeout(() => reject(new Error("launch timed out")), ms);
42
+ });
43
+ try {
44
+ return await Promise.race([p, timeout]);
45
+ } finally {
46
+ if (timer) clearTimeout(timer);
47
+ }
48
+ }
49
+
50
+ 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
+ if (opts.profileDir) args.push(`--user-data-dir=${opts.profileDir}`);
53
+ return args;
54
+ }
55
+
56
+ export function isProfileLockError(message: string): boolean {
57
+ // Chrome's own SingletonLock messages, plus Puppeteer's message when an
58
+ // explicit --user-data-dir is already held by a running browser
59
+ // ("The browser is already running for <dir>. Use a different `userDataDir` …").
60
+ return /singletonlock|processsingleton|profile appears to be in use|create a processsingleton|already running for|use a different\s+`?userdatadir/i.test(
61
+ message,
62
+ );
63
+ }
64
+
65
+ async function tryAttach(browserURL: string): Promise<{ browser: Browser; page: Page } | null> {
66
+ const puppeteer = (await import("puppeteer")).default;
67
+ try {
68
+ const browser = await puppeteer.connect({ browserURL });
69
+ const pages = await browser.pages();
70
+ const page = pages.length ? pages[pickCoDrivePage(pages)]! : await browser.newPage();
71
+ return { browser, page };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ async function launch(executablePath: string, args: string[]): Promise<Browser> {
78
+ const puppeteer = (await import("puppeteer")).default;
79
+ return puppeteer.launch({ headless: false, executablePath, args, defaultViewport: null });
80
+ }
81
+
82
+ export async function acquirePage(opts: {
83
+ settings: { get(key: string): unknown };
84
+ debugPort?: number;
85
+ }): Promise<{ browser: Browser; page: Page; mode: AcquireMode }> {
86
+ const debugPort = opts.debugPort ?? DEFAULT_DEBUG_PORT;
87
+ const configuredUrl = resolveBrowserConnectUrl(opts.settings);
88
+ const attachUrl = configuredUrl ?? `http://127.0.0.1:${debugPort}`;
89
+ assertLoopbackBrowserUrl(attachUrl);
90
+
91
+ // 1) Attach to a Chrome already exposing the debug port.
92
+ const attached = await tryAttach(attachUrl);
93
+ if (attached) return { ...attached, mode: "attached" };
94
+
95
+ // If the user explicitly configured connectUrl but nothing is there, that is an error
96
+ // (do not silently launch a different browser than they asked to attach to).
97
+ if (configuredUrl) {
98
+ throw new Error(
99
+ `Could not attach to Chrome at ${configuredUrl}. Start Chrome with --remote-debugging-port=${debugPort} and retry, or unset browser.connectUrl to let xcsh launch Chrome.`,
100
+ );
101
+ }
102
+
103
+ const located = locateChrome({ settings: opts.settings });
104
+ if (!located) {
105
+ throw new Error(
106
+ "Google Chrome not found. Install Google Chrome, or set the browser.chromePath setting to your Chrome/Chromium executable.",
107
+ );
108
+ }
109
+
110
+ // 2) Launch the user's installed Chrome with their DEFAULT profile + debug port.
111
+ // We pass --user-data-dir EXPLICITLY (Chrome's implicit default profile would hand off
112
+ // to an already-running Chrome and exit, leaving puppeteer.launch hanging on a debug
113
+ // endpoint that never appears). A timeout guards against that handoff case so we can
114
+ // fall through to a dedicated profile instead of hanging.
115
+ const defaultDir = defaultProfileDir();
116
+ if (defaultDir) {
117
+ try {
118
+ const browser = await withLaunchTimeout(
119
+ launch(located.path, buildLaunchArgs({ debugPort, profileDir: defaultDir })),
120
+ 12_000,
121
+ );
122
+ const pages = await browser.pages();
123
+ const page = pages.length ? pages[0]! : await browser.newPage();
124
+ return { browser, page, mode: "launched-default" };
125
+ } catch (err) {
126
+ const msg = err instanceof Error ? err.message : String(err);
127
+ // Profile locked (Chrome already running) or the launch timed out (handoff to a
128
+ // running instance) → fall through to a dedicated, xcsh-owned profile.
129
+ if (!isProfileLockError(msg) && !msg.includes("launch timed out")) throw err;
130
+ }
131
+ }
132
+
133
+ // 3) Default profile unavailable (locked / handoff / unsupported platform) → dedicated
134
+ // xcsh-owned profile. A previous xcsh-launched Chrome on this profile may still be
135
+ // running (close() only disconnects, never terminates), so this launch can hit the
136
+ // same SingletonLock/handoff and hang. Guard it with a timeout; there is no further
137
+ // fallback, so a timeout/lock error is fatal.
138
+ const profileDir = dedicatedProfileDir();
139
+ let browser: Browser;
140
+ try {
141
+ browser = await withLaunchTimeout(launch(located.path, buildLaunchArgs({ debugPort, profileDir })), 12_000);
142
+ } catch (err) {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ if (msg.includes("launch timed out") || isProfileLockError(msg)) {
145
+ throw new Error(
146
+ "Could not launch Chrome on the dedicated xcsh profile (it may already be in use); close other xcsh-launched Chrome windows and retry.",
147
+ );
148
+ }
149
+ throw err;
150
+ }
151
+ const pages = await browser.pages();
152
+ const page = pages.length ? pages[0]! : await browser.newPage();
153
+ return { browser, page, mode: "launched-dedicated" };
154
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Auth preflight for the F5 XC console.
3
+ *
4
+ * Pure predicates (`isLoginWall` / `isAuthenticated`) classify a document as a
5
+ * login wall or an authenticated console shell. They work over a minimal
6
+ * structural Document so they run both under linkedom in tests and against the
7
+ * live `document` (via page.evaluate) in the browser. `ensureAuthenticated` is
8
+ * the browser-bound orchestrator: on a login wall it triggers saved-password
9
+ * autofill + submit, then falls back to a co-drive poll until the operator logs
10
+ * in. It is verified at a later live gate (no unit test drives a real browser).
11
+ *
12
+ * No Puppeteer imports leak into the pure predicates; only `ensureAuthenticated`
13
+ * references the `Page` type.
14
+ */
15
+
16
+ import type { Page } from "puppeteer";
17
+
18
+ /** Minimal structural interface matching both linkedom and browser Document. */
19
+ export interface AuthDocument {
20
+ querySelector(sel: string): unknown;
21
+ querySelectorAll(sel: string): ArrayLike<unknown>;
22
+ }
23
+
24
+ /**
25
+ * Selectors that identify a login form. The real XC login wall is a Keycloak
26
+ * page with `#username`, `#password`, and a `#kc-login` submit; the id/name/type
27
+ * alternatives keep this robust across Keycloak theme variations.
28
+ */
29
+ export const LOGIN_SELECTOR = "#username, #password, input[name='username'], input[type='password']";
30
+
31
+ /**
32
+ * Selector that identifies the authenticated console shell. The XC console
33
+ * decorates its chrome with `ves-`-prefixed component classes; the login wall
34
+ * has none (the only `ves-` strings there live in URL query params, not class
35
+ * attributes).
36
+ */
37
+ export const CONSOLE_SHELL_SELECTOR = "[class*='ves-']";
38
+
39
+ /** True when the document presents a login form (Keycloak login wall). */
40
+ export function isLoginWall(doc: AuthDocument): boolean {
41
+ return doc.querySelector(LOGIN_SELECTOR) != null;
42
+ }
43
+
44
+ /**
45
+ * True when the document is the authenticated console shell: it carries
46
+ * `ves-`-classed chrome and is NOT showing a login form.
47
+ */
48
+ export function isAuthenticated(doc: AuthDocument): boolean {
49
+ return doc.querySelector(CONSOLE_SHELL_SELECTOR) != null && !isLoginWall(doc);
50
+ }
51
+
52
+ /**
53
+ * Source for an injected IIFE that nudges the browser's saved-password manager
54
+ * to autofill the login form and then submits it.
55
+ *
56
+ * It focuses the username/password fields and dispatches synthetic input events
57
+ * so Chrome's credential manager offers its saved entry, then clicks the submit
58
+ * control (`#kc-login`) or, failing that, submits the enclosing form. Returns
59
+ * `true` when a form was found and a submit was attempted.
60
+ *
61
+ * Built as a string (rather than a function reference) so the caller can pass it
62
+ * straight to `page.evaluate`.
63
+ */
64
+ export function triggerSavedPasswordExpr(): string {
65
+ return `(() => {
66
+ const username = document.querySelector("#username, input[name='username']");
67
+ const password = document.querySelector("#password, input[type='password']");
68
+ if (!username && !password) return false;
69
+ const nudge = (el) => {
70
+ if (!el) return;
71
+ el.focus();
72
+ el.dispatchEvent(new Event("input", { bubbles: true }));
73
+ el.dispatchEvent(new Event("change", { bubbles: true }));
74
+ };
75
+ nudge(username);
76
+ nudge(password);
77
+ const submit = document.querySelector("#kc-login, button[type='submit'], input[type='submit']");
78
+ if (submit) { submit.click(); return true; }
79
+ const form = (password || username || {}).form;
80
+ if (form && typeof form.requestSubmit === "function") { form.requestSubmit(); return true; }
81
+ if (form && typeof form.submit === "function") { form.submit(); return true; }
82
+ return false;
83
+ })()`;
84
+ }
85
+
86
+ export interface EnsureAuthenticatedOptions {
87
+ /** Co-drive poll interval in ms (default 1000). */
88
+ pollIntervalMs?: number;
89
+ /** Total time to wait for the operator to complete login in ms (default 300000 = 5 min). */
90
+ timeoutMs?: number;
91
+ /** Optional callback invoked once when login is required, so callers can surface a co-drive prompt. */
92
+ onLoginRequired?: () => void;
93
+ }
94
+
95
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
96
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
97
+
98
+ function sleep(ms: number): Promise<void> {
99
+ return new Promise(resolve => setTimeout(resolve, ms));
100
+ }
101
+
102
+ /**
103
+ * Compute the auth state against the live document inside the page.
104
+ *
105
+ * Self-contained: the selectors are passed as `page.evaluate` ARGS so nothing
106
+ * relies on closure or sibling-function scope (those identifiers do not exist in
107
+ * the page realm). Mirrors the pure `isLoginWall` / `isAuthenticated` predicates,
108
+ * which remain the documented logic over the same two selector consts.
109
+ */
110
+ async function evalAuthState(page: Page): Promise<{ loginWall: boolean; authed: boolean }> {
111
+ return page.evaluate(
112
+ (loginSel, shellSel) => {
113
+ const doc = (globalThis as unknown as { document: { querySelector(s: string): unknown } }).document;
114
+ const loginWall = doc.querySelector(loginSel) != null;
115
+ const authed = doc.querySelector(shellSel) != null && !loginWall;
116
+ return { loginWall, authed };
117
+ },
118
+ LOGIN_SELECTOR,
119
+ CONSOLE_SHELL_SELECTOR,
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Ensure the console at `consoleUrl` is authenticated.
125
+ *
126
+ * 1. Navigate to `consoleUrl`.
127
+ * 2. If already authenticated, return immediately.
128
+ * 3. If on a login wall, trigger saved-password autofill + submit once.
129
+ * 4. Poll until authenticated (operator co-drive) or the timeout elapses.
130
+ *
131
+ * Throws if the timeout elapses before authentication succeeds.
132
+ */
133
+ export async function ensureAuthenticated(
134
+ page: Page,
135
+ consoleUrl: string,
136
+ opts: EnsureAuthenticatedOptions = {},
137
+ ): Promise<void> {
138
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
139
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
140
+
141
+ await page.goto(consoleUrl, { waitUntil: "domcontentloaded" });
142
+
143
+ {
144
+ const { authed } = await evalAuthState(page);
145
+ if (authed) return;
146
+ }
147
+
148
+ let nudged = false;
149
+ let loginAnnounced = false;
150
+ const deadline = Date.now() + timeoutMs;
151
+
152
+ while (Date.now() < deadline) {
153
+ const { loginWall, authed } = await evalAuthState(page);
154
+ if (authed) return;
155
+
156
+ if (loginWall) {
157
+ if (!loginAnnounced) {
158
+ opts.onLoginRequired?.();
159
+ loginAnnounced = true;
160
+ }
161
+ if (!nudged) {
162
+ // Try saved-password autofill + submit exactly once; subsequent
163
+ // passes fall through to co-drive polling so we don't fight the
164
+ // operator if autofill failed or no credential was saved.
165
+ await page.evaluate(triggerSavedPasswordExpr());
166
+ nudged = true;
167
+ }
168
+ }
169
+
170
+ await sleep(pollIntervalMs);
171
+ }
172
+
173
+ throw new Error(
174
+ `Timed out after ${timeoutMs}ms waiting for authentication at ${consoleUrl}; the console is still showing a login wall.`,
175
+ );
176
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Browser, CDPSession, Page } from "puppeteer";
2
- import { assertLoopbackBrowserUrl, pickCoDrivePage, resolveBrowserConnectUrl } from "../tools/browser";
2
+ import type { AcquireMode } from "./acquire";
3
3
 
4
4
  type Settings = { get(key: string): unknown };
5
5
 
@@ -7,24 +7,20 @@ export class BrowserSession {
7
7
  #browser: Browser | null = null;
8
8
  #page: Page | null = null;
9
9
  #cdp: CDPSession | null = null;
10
- #attached = false;
10
+ #mode: AcquireMode | null = null;
11
11
  constructor(private readonly settings: Settings) {}
12
12
 
13
+ get mode(): AcquireMode | null {
14
+ return this.#mode;
15
+ }
16
+
13
17
  async ensurePage(): Promise<Page> {
14
18
  if (this.#page && !this.#page.isClosed()) return this.#page;
15
- const puppeteer = (await import("puppeteer")).default;
16
- const connectUrl = resolveBrowserConnectUrl(this.settings);
17
- if (connectUrl) {
18
- assertLoopbackBrowserUrl(connectUrl);
19
- this.#browser = await puppeteer.connect({ browserURL: connectUrl });
20
- this.#attached = true;
21
- const pages = await this.#browser.pages();
22
- this.#page = pages.length ? pages[pickCoDrivePage(pages)]! : await this.#browser.newPage();
23
- } else {
24
- this.#browser = await puppeteer.launch({ headless: !!this.settings.get("browser.headless") });
25
- this.#attached = false;
26
- this.#page = await this.#browser.newPage();
27
- }
19
+ const { acquirePage } = await import("./acquire");
20
+ const { browser, page, mode } = await acquirePage({ settings: this.settings });
21
+ this.#browser = browser;
22
+ this.#page = page;
23
+ this.#mode = mode;
28
24
  this.#cdp = null;
29
25
  return this.#page;
30
26
  }
@@ -37,12 +33,13 @@ export class BrowserSession {
37
33
 
38
34
  async close(): Promise<void> {
39
35
  if (this.#browser) {
40
- if (this.#attached) await this.#browser.disconnect();
41
- else await this.#browser.close();
36
+ // Never terminate the user's Chrome (attached or launched against their profile);
37
+ // just detach. Puppeteer's disconnect leaves the browser running.
38
+ await this.#browser.disconnect().catch(() => {});
42
39
  }
43
40
  this.#browser = null;
44
41
  this.#page = null;
45
42
  this.#cdp = null;
46
- this.#attached = false;
43
+ this.#mode = null;
47
44
  }
48
45
  }
@@ -0,0 +1,66 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+
5
+ export interface LocateChromeOpts {
6
+ settings?: { get(key: string): unknown };
7
+ platform?: NodeJS.Platform;
8
+ env?: NodeJS.ProcessEnv;
9
+ exists?: (p: string) => boolean;
10
+ which?: (cmd: string) => string | null;
11
+ }
12
+
13
+ export interface LocatedChrome {
14
+ path: string;
15
+ source: "setting" | "macos" | "path" | "nixos" | "windows";
16
+ }
17
+
18
+ function defaultWhich(cmd: string): string | null {
19
+ try {
20
+ const out = execFileSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf-8" }).trim();
21
+ return out || null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function locateChrome(opts: LocateChromeOpts = {}): LocatedChrome | null {
28
+ const platform = opts.platform ?? process.platform;
29
+ const env = opts.env ?? process.env;
30
+ const exists = opts.exists ?? ((p: string) => fs.existsSync(p));
31
+ const which = opts.which ?? defaultWhich;
32
+
33
+ const override = opts.settings?.get("browser.chromePath");
34
+ if (typeof override === "string" && override.trim() && exists(override.trim())) {
35
+ return { path: override.trim(), source: "setting" };
36
+ }
37
+
38
+ if (platform === "darwin") {
39
+ const macCandidates = [
40
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
41
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
42
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
43
+ ];
44
+ for (const c of macCandidates) if (exists(c)) return { path: c, source: "macos" };
45
+ return null;
46
+ }
47
+
48
+ if (platform === "win32") {
49
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean) as string[];
50
+ for (const root of roots) {
51
+ const c = path.join(root, "Google", "Chrome", "Application", "chrome.exe");
52
+ if (exists(c)) return { path: c, source: "windows" };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ // linux / other unix
58
+ for (const cmd of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"]) {
59
+ const found = which(cmd);
60
+ if (found) return { path: found, source: "path" };
61
+ }
62
+ for (const c of [path.join(env.HOME ?? "", ".nix-profile/bin/chromium"), "/run/current-system/sw/bin/chromium"]) {
63
+ if (c && exists(c)) return { path: c, source: "nixos" };
64
+ }
65
+ return null;
66
+ }
@@ -1,6 +1,9 @@
1
+ export * from "./acquire";
1
2
  export * from "./actions";
3
+ export * from "./auth";
2
4
  export * from "./ax";
3
5
  export * from "./cdp-core";
6
+ export * from "./chrome-locate";
4
7
  export * from "./dom-context";
5
8
  export * from "./input-commit";
6
9
  export * from "./resolver";
@@ -1408,6 +1408,17 @@ export const SETTINGS_SCHEMA = {
1408
1408
  },
1409
1409
  },
1410
1410
 
1411
+ "browser.chromePath": {
1412
+ type: "string",
1413
+ default: undefined,
1414
+ ui: {
1415
+ tab: "tools",
1416
+ label: "Chrome executable path (override)",
1417
+ description:
1418
+ "Absolute path to a Chrome/Chromium executable. Overrides auto-detection of the installed browser used for console automation. Leave unset to auto-detect.",
1419
+ },
1420
+ },
1421
+
1411
1422
  "browser.screenshotDir": {
1412
1423
  type: "string",
1413
1424
  default: undefined,
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.35.1",
21
- "commit": "859c5bbaeadef1b3556025c624f25a0597a74b2c",
22
- "shortCommit": "859c5bb",
20
+ "version": "19.36.0",
21
+ "commit": "25f2668aebf96b7025f2b1abc9646adcd929d0b0",
22
+ "shortCommit": "25f2668",
23
23
  "branch": "main",
24
- "tag": "v19.35.1",
25
- "commitDate": "2026-06-19T11:39:13Z",
26
- "buildDate": "2026-06-19T11:59:24.040Z",
24
+ "tag": "v19.36.0",
25
+ "commitDate": "2026-06-19T19:08:49Z",
26
+ "buildDate": "2026-06-19T19:30:15.957Z",
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/859c5bbaeadef1b3556025c624f25a0597a74b2c",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.35.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/25f2668aebf96b7025f2b1abc9646adcd929d0b0",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.36.0"
33
33
  };
@@ -28,7 +28,7 @@ export const CONSOLE_CATALOG_DATA: ConsoleCatalogData = {
28
28
  "demos/waap-full-stack-teardown":
29
29
  'schema: "urn:f5xc:console:workflow:v1"\nid: "waap-full-stack-teardown"\nlabel: "WAAP Full Stack Teardown — Delete HTTP LB + App Firewall + Origin Pool"\nresource:\n - "http-load-balancer"\n - "app-firewall"\n - "origin-pool"\noperation: "demo"\n\npreconditions:\n - "user_logged_in"\n - "namespace_selected"\n - "role_minimum: admin"\n\nparams:\n namespace:\n required: true\n description: "Namespace containing the resources"\n example: "demo"\n app_name:\n required: true\n description: "Base name used when creating resources"\n example: "example-app"\n\nsteps:\n - id: "delete-http-lb"\n action: "run-workflow"\n workflow: "http-load-balancer/delete"\n with:\n namespace: "{namespace}"\n name: "{app_name}-lb"\n description: "Step 1: Delete the HTTP Load Balancer (must be deleted before its dependencies)"\n\n - id: "delete-app-firewall"\n action: "run-workflow"\n workflow: "app-firewall/delete"\n with:\n namespace: "{namespace}"\n name: "{app_name}-waf"\n description: "Step 2: Delete the App Firewall policy"\n\n - id: "delete-origin-pool"\n action: "run-workflow"\n workflow: "origin-pool/delete"\n with:\n namespace: "{namespace}"\n name: "{app_name}-pool"\n description: "Step 3: Delete the Origin Pool"\n\npostconditions:\n - "resource_deleted: {app_name}-lb"\n - "resource_deleted: {app_name}-waf"\n - "resource_deleted: {app_name}-pool"\n\nmetadata:\n confidence: "draft"\n discovered_at: "2026-06-16"\n console_version: "2025.06"\n estimated_duration_seconds: 60\n notes: >\n Teardown companion for waap-full-stack demo. Deletes resources in\n reverse dependency order: HTTP LB first (references origin pool and\n app firewall), then app firewall, then origin pool.\n',
30
30
  "health-check/create":
31
- 'schema: "urn:f5xc:console:workflow:v1"\nid: "health-check-create"\nlabel: "Create Health Check"\nresource: "health-check"\noperation: "create"\n\npreconditions:\n - "user_logged_in"\n - "namespace_selected"\n - "role_minimum: admin"\n\nparams:\n namespace:\n required: true\n description: "Target namespace"\n example: "demo"\n name:\n required: true\n description: "Health check name (lowercase alphanumeric and hyphens)"\n example: "example-health-check"\n health_check_type:\n required: false\n description: "Health check protocol type"\n example: "HTTP HealthCheck"\n default: "HTTP HealthCheck"\n timeout:\n required: false\n type: "number"\n description: "Timeout for each health check probe in seconds"\n example: 3\n default: 3\n interval:\n required: false\n type: "number"\n description: "Interval between health check probes in seconds"\n example: 15\n default: 15\n unhealthy_threshold:\n required: false\n type: "number"\n description: "Number of consecutive failures before marking unhealthy"\n example: 1\n default: 1\n healthy_threshold:\n required: false\n type: "number"\n description: "Number of consecutive successes before marking healthy"\n example: 3\n default: 3\n\nsteps:\n - id: "navigate-to-list"\n action: "navigate"\n url: "/web/workspaces/web-app-and-api-protection/namespaces/{namespace}/manage/load_balancers/health_checks"\n wait_for: "text(\'Health Checks\')"\n description: "Navigate to Health Checks list page within Web App & API Protection workspace"\n\n - id: "click-add-tab"\n action: "click"\n selector: "tab:text(\'Add Health Check\')"\n wait_for: "textbox[name=\'Name\']"\n description: "Click the Add Health Check tab to open the inline create form"\n note: "This is a tab element, not a button"\n\n - id: "fill-name"\n action: "fill"\n selector: "textbox[name=\'Name\']"\n value: "{name}"\n description: "Enter the health check name in the Name textbox"\n\n - id: "select-type"\n condition: "params.health_check_type is set"\n action: "select"\n selector: "listbox"\n context: "Health Check Parameters section"\n value: "{health_check_type}"\n description: "Select the health check type (HTTP HealthCheck or TCP HealthCheck)"\n\n - id: "set-timeout"\n condition: "params.timeout is set"\n action: "fill"\n selector: "spinbutton[name=\'Timeout\']"\n value: "{timeout}"\n description: "Set the health check timeout value"\n\n - id: "set-interval"\n condition: "params.interval is set"\n action: "fill"\n selector: "spinbutton[name=\'Interval\']"\n value: "{interval}"\n description: "Set the health check interval value"\n\n - id: "set-unhealthy-threshold"\n condition: "params.unhealthy_threshold is set"\n action: "fill"\n selector: "spinbutton[name=\'Unhealthy Threshold\']"\n value: "{unhealthy_threshold}"\n description: "Set the unhealthy threshold value"\n\n - id: "set-healthy-threshold"\n condition: "params.healthy_threshold is set"\n action: "fill"\n selector: "spinbutton[name=\'Healthy Threshold\']"\n value: "{healthy_threshold}"\n description: "Set the healthy threshold value"\n\n - id: "save"\n action: "click"\n selector: "button:text(\'Add Health Check\')"\n context: "footer"\n wait_for: "text(\'{name}\')"\n wait_timeout_ms: 30000\n description: "Click the Add Health Check button in the form footer to save"\n\n - id: "verify-created"\n action: "assert"\n selector: "text(\'{name}\')"\n description: "Verify the resource was created by checking the name appears in the list"\n\npostconditions:\n - "resource_list_page_visible"\n - "resource_name_in_list: {name}"\n\nmetadata:\n confidence: "validated"\n discovered_at: "2026-06-15"\n validated_at: "2026-06-15"\n console_version: "2025.06"\n estimated_duration_seconds: 25\n notes: "Selectors use aria/text-based patterns validated against live console. No data-testid attributes exist. Has Show Advanced Fields toggle for additional parameters."\n',
31
+ 'schema: "urn:f5xc:console:workflow:v1"\nid: "health-check-create"\nlabel: "Create Health Check"\nresource: "health-check"\noperation: "create"\n\npreconditions:\n - "user_logged_in"\n - "namespace_selected"\n - "role_minimum: admin"\n\nparams:\n namespace:\n required: true\n description: "Target namespace"\n example: "demo"\n name:\n required: true\n description: "Health check name (lowercase alphanumeric and hyphens)"\n example: "example-health-check"\n health_check_type:\n required: false\n description: "Health check protocol type"\n example: "HTTP HealthCheck"\n default: "HTTP HealthCheck"\n timeout:\n required: false\n type: "number"\n description: "Timeout for each health check probe in seconds"\n example: 3\n default: 3\n interval:\n required: false\n type: "number"\n description: "Interval between health check probes in seconds"\n example: 15\n default: 15\n unhealthy_threshold:\n required: false\n type: "number"\n description: "Number of consecutive failures before marking unhealthy"\n example: 1\n default: 1\n healthy_threshold:\n required: false\n type: "number"\n description: "Number of consecutive successes before marking healthy"\n example: 3\n default: 3\n\nsteps:\n - id: "navigate-to-list"\n action: "navigate"\n url: "/web/workspaces/web-app-and-api-protection/namespaces/{namespace}/manage/load_balancers/health_checks"\n wait_for: "text(\'Health Checks\')"\n description: "Navigate to Health Checks list page within Web App & API Protection workspace"\n\n - id: "click-add-tab"\n action: "click"\n selector: "tab:text(\'Add Health Check\')"\n wait_for: "textbox[name=\'Name\']"\n description: "Click the Add Health Check tab to open the inline create form"\n note: "This is a tab element, not a button"\n\n - id: "fill-name"\n action: "fill"\n selector: "textbox[name=\'Name\']"\n value: "{name}"\n description: "Enter the health check name in the Name textbox"\n\n - id: "select-type"\n condition: "params.health_check_type is set"\n action: "select"\n selector: "listbox"\n context: "Health Check Parameters section"\n value: "{health_check_type}"\n description: "Select the health check type (HTTP HealthCheck or TCP HealthCheck)"\n\n - id: "set-timeout"\n condition: "params.timeout is set"\n action: "fill"\n selector: "spinbutton[name=\'Timeout\']"\n value: "{timeout}"\n description: "Set the health check timeout value"\n\n - id: "set-interval"\n condition: "params.interval is set"\n action: "fill"\n selector: "spinbutton[name=\'Interval\']"\n value: "{interval}"\n description: "Set the health check interval value"\n\n - id: "set-unhealthy-threshold"\n condition: "params.unhealthy_threshold is set"\n action: "fill"\n selector: "spinbutton[name=\'Unhealthy Threshold\']"\n value: "{unhealthy_threshold}"\n description: "Set the unhealthy threshold value"\n\n - id: "set-healthy-threshold"\n condition: "params.healthy_threshold is set"\n action: "fill"\n selector: "spinbutton[name=\'Healthy Threshold\']"\n value: "{healthy_threshold}"\n description: "Set the healthy threshold value"\n\n - id: "save"\n action: "click"\n selector: "button:text(\'Add Health Check\')"\n wait_for: "text(\'{name}\')"\n wait_timeout_ms: 30000\n description: "Click the Add Health Check button to save. The \'button\' role disambiguates the footer save button from the same-named tab, so no section context is needed. The wait_for confirms the new resource\'s name appears in the list, which is the creation verification."\n\npostconditions:\n - "resource_list_page_visible"\n - "resource_name_in_list: {name}"\n\nmetadata:\n confidence: "validated"\n discovered_at: "2026-06-15"\n validated_at: "2026-06-18"\n console_version: "2025.06"\n estimated_duration_seconds: 25\n notes: "Selectors use aria/text-based patterns validated against live console. No data-testid attributes exist. Has Show Advanced Fields toggle for additional parameters."\n',
32
32
  "health-check/delete":
33
33
  'schema: "urn:f5xc:console:workflow:v1"\nid: "health-check-delete"\nlabel: "Delete Health Check"\nresource: "health-check"\noperation: "delete"\n\npreconditions:\n - "user_logged_in"\n - "namespace_selected"\n - "role_minimum: admin"\n - "resource_exists: {name}"\n\nparams:\n namespace:\n required: true\n description: "Namespace containing the health check"\n example: "demo"\n name:\n required: true\n description: "Name of the health check to delete"\n example: "example-health-check"\n\nsteps:\n - id: "navigate-to-list"\n action: "navigate"\n url: "/web/namespace/{namespace}/load-balancers/health-checks"\n wait_for: "[data-testid=\'resource-table\']"\n description: "Navigate to Health Checks list page"\n\n - id: "find-row"\n action: "assert"\n selector: "[data-testid=\'resource-row-{name}\']"\n expected_text: "{name}"\n description: "Locate the target resource row in the table"\n\n - id: "open-actions"\n action: "click"\n selector: "[data-testid=\'resource-row-{name}\'] [data-testid=\'row-actions\']"\n wait_for: "[data-testid=\'action-delete\']"\n description: "Open the row actions menu"\n\n - id: "click-delete"\n action: "click"\n selector: "[data-testid=\'action-delete\']"\n wait_for: "[data-testid=\'confirm-delete-modal\']"\n description: "Click Delete to open the confirmation modal"\n\n - id: "confirm-delete"\n action: "click"\n selector: "[data-testid=\'confirm-delete-btn\']"\n wait_for: "[data-testid=\'resource-table\']"\n wait_timeout_ms: 15000\n description: "Confirm deletion and wait for the table to refresh"\n\n - id: "verify-deleted"\n action: "assert"\n selector: "[data-testid=\'resource-table\']"\n description: "Verify the resource no longer appears in the table"\n\npostconditions:\n - "resource_removed_from_list"\n - "list_page_visible"\n\nmetadata:\n confidence: "draft"\n discovered_at: "2026-06-15"\n console_version: "2025.06"\n estimated_duration_seconds: 15\n',
34
34
  "http-load-balancer/clone":
@@ -23,6 +23,20 @@ function operationsFor(resource: string, catalog: ConsoleCatalogData): string[]
23
23
  .map(k => k.slice(prefix.length));
24
24
  }
25
25
 
26
+ const normKey = (s: string): string => s.toLowerCase().replace(/[-_\s]+/g, "");
27
+
28
+ export function canonicalizeResource(name: string, catalog: ConsoleCatalogData): string | null {
29
+ const target = normKey(name);
30
+ const keys = Object.keys(catalog.resources);
31
+ // exact-normalized match first
32
+ let hit = keys.find(k => normKey(k) === target);
33
+ if (hit) return hit;
34
+ // tolerate trailing plural 's'
35
+ const singular = target.replace(/s$/, "");
36
+ hit = keys.find(k => normKey(k) === singular || normKey(k).replace(/s$/, "") === target);
37
+ return hit ?? null;
38
+ }
39
+
26
40
  function renderIndex(catalog: ConsoleCatalogData): string {
27
41
  const lines = [`# F5 XC Console Catalogue (v${catalog.version})`, "", "## Resources", ""];
28
42
  for (const id of Object.keys(catalog.resources).sort()) {
@@ -33,27 +47,43 @@ function renderIndex(catalog: ConsoleCatalogData): string {
33
47
  }
34
48
 
35
49
  function renderResource(resource: string, catalog: ConsoleCatalogData): string {
36
- const raw = catalog.resources[resource];
37
- if (!raw) return `# Unknown console resource: ${resource}\n`;
50
+ const key = canonicalizeResource(resource, catalog) ?? resource;
51
+ const raw = catalog.resources[key];
52
+ if (!raw) {
53
+ const available = Object.keys(catalog.resources)
54
+ .sort()
55
+ .map(id => {
56
+ const ops = operationsFor(id, catalog);
57
+ return `- \`${id}\`${ops.length ? ` (${ops.join(", ")})` : ""}`;
58
+ });
59
+ return `# Unknown console resource: ${resource}\n\n## Available resources\n\n${available.join("\n")}\n`;
60
+ }
38
61
  const doc = (parseYaml(raw) ?? {}) as Record<string, unknown>;
39
62
  const console_ = (doc.console ?? {}) as Record<string, unknown>;
40
- const lines = [`# ${(doc.label as string | undefined) ?? resource}`, ""];
63
+ const lines = [`# ${(doc.label as string | undefined) ?? key}`, ""];
41
64
  if (console_.route_pattern) lines.push(`**Route:** \`${console_.route_pattern}\``, "");
42
65
  if (Array.isArray(console_.menu_path)) lines.push(`**Menu:** ${(console_.menu_path as string[]).join(" › ")}`, "");
43
- const ops = operationsFor(resource, catalog);
66
+ const ops = operationsFor(key, catalog);
44
67
  if (ops.length) {
45
68
  lines.push("## Operations", "");
46
- for (const op of ops) lines.push(`- \`xcsh://console/${resource}/${op}\` (${op})`);
69
+ for (const op of ops) lines.push(`- \`xcsh://console/${key}/${op}\` (${op})`);
47
70
  }
48
71
  return `${lines.join("\n")}\n`;
49
72
  }
50
73
 
51
74
  function renderWorkflow(resource: string, operation: string, catalog: ConsoleCatalogData): string {
52
- const raw = catalog.workflows[`${resource}/${operation}`];
53
- if (!raw) return `# No console workflow for ${resource}/${operation}\n`;
75
+ const key = canonicalizeResource(resource, catalog) ?? resource;
76
+ const raw = catalog.workflows[`${key}/${operation}`];
77
+ if (!raw) {
78
+ const ops = operationsFor(key, catalog);
79
+ const opsList = ops.length
80
+ ? `\n\n## Available operations for \`${key}\`\n\n${ops.map(op => `- \`${op}\``).join("\n")}`
81
+ : "";
82
+ return `# No console workflow for ${key}/${operation}${opsList}\n`;
83
+ }
54
84
  const doc = (parseYaml(raw) ?? {}) as Record<string, unknown>;
55
85
  const steps = Array.isArray(doc.steps) ? (doc.steps as Record<string, unknown>[]) : [];
56
- const lines = [`# ${(doc.label as string | undefined) ?? `${resource} ${operation}`}`, "", "## Steps", ""];
86
+ const lines = [`# ${(doc.label as string | undefined) ?? `${key} ${operation}`}`, "", "## Steps", ""];
57
87
  for (const s of steps) {
58
88
  const sel = s.selector ? ` \`${s.selector}\`` : "";
59
89
  const val = s.value != null ? ` = ${JSON.stringify(s.value)}` : "";
@@ -317,7 +317,14 @@ Most tools resolve custom protocol URLs to internal resources (not web URLs):
317
317
  Also available: `xcsh://api-spec/workflows/` (step-by-step guides),
318
318
  `xcsh://api-spec/errors/{code}` (error resolution), `xcsh://api-spec/glossary/` (acronym reference).
319
319
 
320
- When the user asks *where* or *how* something is configured in the console, consult `xcsh://console/<resource>` before answering. For mutations, the **API path is the default**. Use the browser path (the `catalog_workflow_runner` tool) only when the user asks to *see it in the console*, requests a demo/walkthrough/training, or the operation is UI-only. The browser path requires a Chrome attached via `browser.connectUrl`.
320
+ When the user asks *where* or *how* something is configured in the console, consult `xcsh://console/<resource>` before answering. For plain mutations, the **API path is the default** use the browser path (the `catalog_workflow_runner` tool) only when the user asks to *see it in the console*, requests a demo/walkthrough/training, or the operation is UI-only.
321
+
322
+ **Console / browser automation (one-shot, deterministic).** When the user asks to do something *in the console* or says *"use chrome"*:
323
+ 1. Read `xcsh://console/` ONCE to get the canonical resource id. Resource names are hyphenated (e.g. it is `health-check`, not `healthcheck`) — do **NOT** guess name variants. The index lists every resource and its operations.
324
+ 2. Read `xcsh://console/<resource>/<operation>` once for the step plan.
325
+ 3. Call `catalog_workflow_runner` with `resource`, `operation`, and only the parameters the user supplied (e.g. `name`). Do **NOT** pass or ask for `namespace` or `base_url` — the runner fills them from the active tenant context.
326
+
327
+ An explicit *"use chrome"* means the browser path **only**: do not create the resource via the `xcsh_api` tool as a substitute. The runner launches/attaches Chrome automatically — it does **NOT** require a manually pre-attached Chrome. If login is required, the runner waits for the user in the visible Chrome window.
321
328
 
322
329
  In `bash`, URIs auto-resolve to filesystem paths (e.g., `python skill://my-skill/scripts/init.py`).
323
330
 
@@ -390,6 +397,7 @@ HARD OVERRIDE — F5 Distributed Cloud Terraform Provider:
390
397
  provider "f5xc" {}
391
398
  ```
392
399
  - Authentication is supplied via environment variables (set exactly ONE method): `F5XC_API_TOKEN`; or `F5XC_P12_FILE` + `F5XC_P12_PASSWORD`; or `F5XC_CERT` + `F5XC_KEY`. Tenant URL via `F5XC_API_URL`. Keep the `provider "f5xc" {}` block empty unless the user asks to hardcode credentials.
400
+ - Write vs run: "write a terraform plan" produces an artifact — write the `.tf`, then `terraform fmt` + `terraform init` (best-effort) + `terraform validate` to deliver a formatted, verified file. If `init` fails (e.g. `dev_overrides`/offline), still run `terraform validate` and report. Do **NOT** auto-run `terraform plan` (only on explicit plan/preview request) and **NEVER** run `terraform apply` unless the user clearly asks to create/CRUD. Writing a plan is not running it.
393
401
  - Consult xcsh://branding/terraform proactively when context involves Terraform.
394
402
 
395
403
  # Skills
@@ -191,9 +191,9 @@ Swap exactly one block per oneOf group — e.g. `enable_ha {}` replaces `disable
191
191
  - `custom_proxy_bypass { proxy_bypass = ["10.0.0.0/8"] }` ← use `proxy_bypass` (NOT `bypass_list`)
192
192
  - `blocked_services { blocked_service { network_type = "VIRTUAL_NETWORK_SITE_LOCAL" } }` ← use `blocked_service` with `network_type` (NOT `service_list`). In Terraform, "blocking HTTP services" = blocking `VIRTUAL_NETWORK_SITE_LOCAL` network type. Always write the file even when the phrase mentions "HTTP service in blocked services list" — map it to `blocked_service { network_type = "VIRTUAL_NETWORK_SITE_LOCAL" }`
193
193
 
194
- **CRITICAL — Terraform file write rule**: When asked to "Write Terraform HCL for f5xc_securemesh_site_v2", you **MUST** use the `xcsh_write_file` tool to write the complete `.tf` file to disk. Always name the file after the resource name in the request (e.g., `ar-test-smsv2-1a.tf`). Do NOT just return a coverage table — always write the actual HCL file. The file must include a `terraform { required_providers { f5xc = { source = "f5xc-salesdemos/f5xc" } } }` block, a `provider "f5xc" {}` block (**REQUIRED** — without it `terraform plan` fails with "Provider requires explicit configuration"), and the complete `resource "f5xc_securemesh_site_v2"` block with all 12 oneOf groups.
194
+ **CRITICAL — Terraform file write rule**: When asked to "Write Terraform HCL for f5xc_securemesh_site_v2", you **MUST** use the `xcsh_write_file` tool to write the complete `.tf` file to disk. Always name the file after the resource name in the request (e.g., `ar-test-smsv2-1a.tf`). Do NOT just return a coverage table — always write the actual HCL file. The file must include a `terraform { required_providers { f5xc = { source = "f5xc-salesdemos/f5xc" } } }` block, a `provider "f5xc" {}` block (**REQUIRED** — without it `terraform plan` fails with "Provider requires explicit configuration"), and the complete `resource "f5xc_securemesh_site_v2"` block with all 12 oneOf groups. After writing, verify without mutating: `terraform fmt` then `terraform init` (best-effort) + `terraform validate`; report the result. Do NOT run `terraform apply` (unless the user asks to create/CRUD) or auto-run `terraform plan`.
195
195
 
196
- **HTTP/HTTPS Load Balancer Terraform HCL (`f5xc_http_loadbalancer`)** — Use `resource "f5xc_http_loadbalancer"` in any namespace. Must include `terraform { required_providers { f5xc = { source = "f5xc-salesdemos/f5xc" } } }` block AND a `provider "f5xc" {}` block (**REQUIRED** — without it `terraform plan` fails with "Provider requires explicit configuration"). Always write file with `xcsh_write_file`. Name the file after the resource name (e.g., `ar-test-lb-https-1.tf`).
196
+ **HTTP/HTTPS Load Balancer Terraform HCL (`f5xc_http_loadbalancer`)** — Use `resource "f5xc_http_loadbalancer"` in any namespace. Must include `terraform { required_providers { f5xc = { source = "f5xc-salesdemos/f5xc" } } }` block AND a `provider "f5xc" {}` block (**REQUIRED** — without it `terraform plan` fails with "Provider requires explicit configuration"). Always write file with `xcsh_write_file`. Name the file after the resource name (e.g., `ar-test-lb-https-1.tf`). After writing, verify without mutating: `terraform fmt` then `terraform init` (best-effort) + `terraform validate`; report the result. Do NOT run `terraform apply` (unless the user asks to create/CRUD) or auto-run `terraform plan`.
197
197
 
198
198
  **CRITICAL — Terraform HCL single-line block rule**: A block definition like `outer { inner {} }` is INVALID when `inner {}` is itself a block (not an attribute). Nested blocks **MUST** be on their own lines:
199
199
  - WRONG: `tls_config { default_security {} }`
@@ -365,6 +365,16 @@ export class ContextService {
365
365
  return path.join(this.#configDir, "active_context");
366
366
  }
367
367
 
368
+ /** Active context's API/console base URL, or null when no context is active. */
369
+ get activeApiUrl(): string | null {
370
+ return this.#activeContext?.apiUrl ?? null;
371
+ }
372
+
373
+ /** Active context's default namespace, or null when no context is active. */
374
+ get activeNamespace(): string | null {
375
+ return this.#activeContext?.defaultNamespace ?? null;
376
+ }
377
+
368
378
  async loadActive(): Promise<F5XCContext | null> {
369
379
  // FR-102: F5XC_API_URL is the signal to skip context loading entirely.
370
380
  // Subprocesses inherit process.env, so they already see the env vars directly.
@@ -75,14 +75,7 @@ export async function handleExportResourceCommand(
75
75
  namespace: ns,
76
76
  });
77
77
 
78
- if (parsed.outputFormat === "hcl") {
79
- ctx.showStatus(
80
- 'HCL export requires AI-assisted transformation. Use a conversational request instead:\n "Export my-lb as Terraform HCL"',
81
- );
82
- return;
83
- }
84
-
85
- const fmt = parsed.outputFormat as "json" | "yaml";
78
+ const fmt = parsed.outputFormat;
86
79
 
87
80
  try {
88
81
  const manifests: Array<{ kind: string; metadata: Record<string, unknown>; spec: Record<string, unknown> }> = [];
@@ -8,7 +8,7 @@ import type {
8
8
  AgentToolUpdateCallback,
9
9
  } from "@f5xc-salesdemos/pi-agent-core";
10
10
  import { StringEnum } from "@f5xc-salesdemos/pi-ai";
11
- import { $which, getPuppeteerDir, logger, prompt, Snowflake, untilAborted } from "@f5xc-salesdemos/pi-utils";
11
+ import { getPuppeteerDir, logger, prompt, Snowflake, untilAborted } from "@f5xc-salesdemos/pi-utils";
12
12
  import { Readability } from "@mozilla/readability";
13
13
  import { type Static, Type } from "@sinclair/typebox";
14
14
  import { type HTMLElement, parseHTML } from "linkedom";
@@ -22,6 +22,7 @@ import type {
22
22
  SerializedAXNode,
23
23
  } from "puppeteer";
24
24
  import { click, fill, resolve, scrollIntoView, waitFor } from "../browser";
25
+ import { locateChrome } from "../browser/chrome-locate";
25
26
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
26
27
  import type { ToolSession } from "../sdk";
27
28
  import { resizeImage } from "../utils/image-resize";
@@ -116,54 +117,6 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
116
117
  }
117
118
  }
118
119
 
119
- /**
120
- * On NixOS, Puppeteer's bundled Chromium is a dynamically-linked FHS binary and
121
- * cannot run as-is. Detect the platform and resolve a system-installed Chromium
122
- * so `puppeteer.launch()` can use it instead of the bundled one.
123
- *
124
- * Detection order:
125
- * 1. `chromium` on PATH
126
- * 2. `chromium-browser` on PATH
127
- * 3. ~/.nix-profile/bin/chromium (user profile)
128
- * 4. /run/current-system/sw/bin/chromium (system profile)
129
- *
130
- * Returns `undefined` on non-NixOS systems or when no binary is found, which
131
- * causes Puppeteer to fall back to its default resolution.
132
- */
133
- let _resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
134
- function resolveSystemChromium(): string | undefined {
135
- if (_resolvedChromium !== undefined) return _resolvedChromium ?? undefined;
136
- try {
137
- if (!fs.existsSync("/etc/NIXOS")) {
138
- _resolvedChromium = null;
139
- return undefined;
140
- }
141
- } catch {
142
- _resolvedChromium = null;
143
- return undefined;
144
- }
145
- const candidates = [
146
- $which("chromium"),
147
- $which("chromium-browser"),
148
- path.join(os.homedir(), ".nix-profile/bin/chromium"),
149
- "/run/current-system/sw/bin/chromium",
150
- ];
151
- for (const candidate of candidates) {
152
- if (candidate) {
153
- try {
154
- if (fs.existsSync(candidate)) {
155
- _resolvedChromium = candidate;
156
- logger.debug("NixOS: using system Chromium", { path: candidate });
157
- return candidate;
158
- }
159
- } catch {}
160
- }
161
- }
162
- _resolvedChromium = null;
163
- logger.debug("NixOS detected but no Chromium binary found; Puppeteer may fail to launch");
164
- return undefined;
165
- }
166
-
167
120
  const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
168
121
  const STEALTH_IGNORE_DEFAULT_ARGS = [
169
122
  "--disable-extensions",
@@ -559,7 +512,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
559
512
  this.#browser = await puppeteer.launch({
560
513
  headless: this.#currentHeadless,
561
514
  defaultViewport: this.#currentHeadless ? initialViewport : null,
562
- executablePath: resolveSystemChromium(),
515
+ executablePath: locateChrome()?.path,
563
516
  args: launchArgs,
564
517
  ignoreDefaultArgs: [...STEALTH_IGNORE_DEFAULT_ARGS],
565
518
  });
@@ -9,6 +9,7 @@ import {
9
9
  assertText,
10
10
  BrowserSession,
11
11
  click,
12
+ ensureAuthenticated,
12
13
  fill,
13
14
  pressKey,
14
15
  screenshot,
@@ -18,6 +19,7 @@ import {
18
19
  } from "../browser";
19
20
  import { CONSOLE_CATALOG_DATA } from "../internal-urls/console-catalog.generated";
20
21
  import catalogWorkflowRunnerDescription from "../prompts/tools/catalog-workflow-runner.md" with { type: "text" };
22
+ import { ContextService } from "../services/f5xc-context";
21
23
  import type { ToolSession } from ".";
22
24
  import type { OutputMeta } from "./output-meta";
23
25
  import { ToolError, throwIfAborted } from "./tool-errors";
@@ -562,12 +564,30 @@ export class CatalogWorkflowRunnerTool
562
564
  }
563
565
  }
564
566
 
567
+ // Default the namespace param from the active context when absent.
568
+ if (params.namespace === undefined) {
569
+ try {
570
+ const ns = ContextService.instance.activeNamespace;
571
+ if (ns) params.namespace = ns;
572
+ } catch {
573
+ /* no active context; validateParams will report if required */
574
+ }
575
+ }
576
+
565
577
  this.#validateParams(workflow, params);
566
578
 
567
- // Resolve base URL from params or environment
568
- const baseUrl = inputParams.base_url ?? process.env.F5XC_API_URL ?? "";
579
+ // Resolve base URL: explicit param > env > active context.
580
+ let activeApiUrl: string | null = null;
581
+ try {
582
+ activeApiUrl = ContextService.instance.activeApiUrl;
583
+ } catch {
584
+ activeApiUrl = null; // ContextService not initialized (e.g. unit context)
585
+ }
586
+ const baseUrl = inputParams.base_url ?? process.env.F5XC_API_URL ?? activeApiUrl ?? "";
569
587
  if (!baseUrl) {
570
- throw new ToolError("No base_url provided and F5XC_API_URL env var is not set");
588
+ throw new ToolError(
589
+ "No base_url provided, F5XC_API_URL is not set, and no active tenant context. Run `/context use <name>` or pass base_url.",
590
+ );
571
591
  }
572
592
 
573
593
  // Options
@@ -583,6 +603,7 @@ export class CatalogWorkflowRunnerTool
583
603
  // Open browser session
584
604
  const session = this.#ensureSession();
585
605
  const page = await session.ensurePage();
606
+ await ensureAuthenticated(page, baseUrl);
586
607
 
587
608
  // Execute steps
588
609
  const stepResults: StepResult[] = [];