@f5-sales-demo/xcsh 21.0.0 → 21.2.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": "@f5-sales-demo/xcsh",
4
- "version": "21.0.0",
4
+ "version": "21.2.0",
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",
@@ -61,13 +61,13 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@agentclientprotocol/sdk": "1.3.0",
64
- "@f5-sales-demo/pi-agent-core": "21.0.0",
65
- "@f5-sales-demo/pi-ai": "21.0.0",
66
- "@f5-sales-demo/pi-natives": "21.0.0",
67
- "@f5-sales-demo/pi-resource-management": "21.0.0",
68
- "@f5-sales-demo/pi-tui": "21.0.0",
69
- "@f5-sales-demo/pi-utils": "21.0.0",
70
- "@f5-sales-demo/xcsh-stats": "21.0.0",
64
+ "@f5-sales-demo/pi-agent-core": "21.2.0",
65
+ "@f5-sales-demo/pi-ai": "21.2.0",
66
+ "@f5-sales-demo/pi-natives": "21.2.0",
67
+ "@f5-sales-demo/pi-resource-management": "21.2.0",
68
+ "@f5-sales-demo/pi-tui": "21.2.0",
69
+ "@f5-sales-demo/pi-utils": "21.2.0",
70
+ "@f5-sales-demo/xcsh-stats": "21.2.0",
71
71
  "@mozilla/readability": "^0.6",
72
72
  "@sinclair/typebox": "^0.34",
73
73
  "@xterm/headless": "^6.0",
@@ -15,13 +15,33 @@ export interface ExtensionToolDef {
15
15
  readonly flags?: ExtensionToolFlags;
16
16
  }
17
17
 
18
+ export type ExtensionInteractionMode =
19
+ | "educational"
20
+ | "presentation"
21
+ | "configuration"
22
+ | "screenshot"
23
+ | "annotation";
24
+
25
+ export interface ExtensionChatPromptHints {
26
+ readonly role: string;
27
+ readonly grounding: string;
28
+ readonly referenceLinks: string;
29
+ readonly toolUse: string;
30
+ readonly modes: Readonly<Record<ExtensionInteractionMode, string>>;
31
+ }
32
+
33
+ export interface ExtensionFeatures {
34
+ readonly chat: { readonly promptHints: ExtensionChatPromptHints; readonly [key: string]: unknown };
35
+ readonly [key: string]: unknown;
36
+ }
37
+
18
38
  export interface ExtensionCapabilities {
19
39
  readonly version: string;
20
40
  readonly contractVersion: string;
21
41
  readonly multiPortDiscovery?: boolean;
22
42
  readonly protocol: string;
23
43
  readonly tools: readonly ExtensionToolDef[];
24
- readonly features: Record<string, unknown>;
44
+ readonly features: ExtensionFeatures;
25
45
  }
26
46
 
27
47
  export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
@@ -21,6 +21,7 @@ import {
21
21
  import { LITELLM_LOGIN_MODEL_CHOICES } from "../modes/controllers/login-model";
22
22
  import { extractReferences } from "../references";
23
23
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
24
+ import { EXTENSION_CAPABILITIES } from "./capabilities.generated";
24
25
  import {
25
26
  type ChatDelta,
26
27
  type ChatDone,
@@ -689,14 +690,6 @@ export function classifyChatErrorReason(message: string): ChatErrorReason {
689
690
  return "provider-5xx";
690
691
  }
691
692
 
692
- const MODE_INSTRUCTIONS: Record<InteractionMode, string> = {
693
- educational: "Explain concepts and settings in depth. Help the user understand what they're looking at and why.",
694
- presentation: "Guide a structured walkthrough. Narrate each step clearly for a live audience.",
695
- configuration: "Help the user build or modify F5 XC configuration. Be precise and action-oriented.",
696
- screenshot: "Focus on capturing annotated screenshots that document the current state.",
697
- annotation: "Create on-page teaching annotations that highlight key elements and explain their purpose.",
698
- };
699
-
700
693
  /** Bases a user-attached context path must fall under. Confines grants to the user's
701
694
  * own space (home, the project cwd, temp, external volumes, /opt) and thereby blocks
702
695
  * a client from widening the sandbox to system/credential dirs (`/etc`, `/var`,
@@ -779,11 +772,22 @@ export function composeChatPrompt(
779
772
  const profile = hostProfile(host);
780
773
  parts.push(profile.systemPrompt);
781
774
 
775
+ const promptHints = EXTENSION_CAPABILITIES.features.chat.promptHints;
776
+
782
777
  // Browser hosts ALSO get an interaction mode + the page-context block. Document
783
778
  // hosts get NEITHER: Office sends no page context and has no browser modes; its
784
779
  // tools + document state arrive at runtime via set_host_tools.
785
780
  if (profile.kind === "browser") {
786
- parts.push(`[Chat mode: ${mode}] ${MODE_INSTRUCTIONS[mode]}`);
781
+ parts.push(
782
+ [
783
+ "[Published extension chat contract]",
784
+ promptHints.role,
785
+ promptHints.grounding,
786
+ promptHints.referenceLinks,
787
+ promptHints.toolUse,
788
+ ].join("\n"),
789
+ );
790
+ parts.push(`[Chat mode: ${mode}] ${promptHints.modes[mode]}`);
787
791
  if (context) composeBrowserPageContext(parts, context);
788
792
  }
789
793
 
@@ -46,3 +46,65 @@ export function extractRequestedTools(source: string): string[] {
46
46
  while ((m = re.exec(source)) !== null) out.add(m[1]);
47
47
  return [...out];
48
48
  }
49
+
50
+ interface ToolReferenceManifest {
51
+ readonly contractVersion: string;
52
+ readonly tools: readonly {
53
+ readonly name: string;
54
+ readonly summary: string;
55
+ readonly category: string;
56
+ readonly params: Readonly<Record<string, unknown>>;
57
+ readonly flags?: {
58
+ readonly readOnly?: boolean;
59
+ readonly mutates?: boolean;
60
+ readonly requiresExplainMode?: boolean;
61
+ };
62
+ }[];
63
+ }
64
+
65
+ function compareText(left: string, right: string): number {
66
+ return left < right ? -1 : left > right ? 1 : 0;
67
+ }
68
+
69
+ /** Stable JSON with object keys sorted recursively and array order preserved. */
70
+ function canonicalJson(value: unknown): string {
71
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
72
+ if (value !== null && typeof value === "object") {
73
+ const entries = Object.entries(value as Record<string, unknown>).sort(([left], [right]) =>
74
+ compareText(left, right),
75
+ );
76
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
77
+ }
78
+ return JSON.stringify(value) ?? "null";
79
+ }
80
+
81
+ /** Render the complete extension tool surface as deterministic Markdown. */
82
+ export function renderToolReference(manifest: ToolReferenceManifest): string {
83
+ const categories = new Map<string, ToolReferenceManifest["tools"][number][]>();
84
+ for (const tool of manifest.tools) {
85
+ const tools = categories.get(tool.category) ?? [];
86
+ tools.push(tool);
87
+ categories.set(tool.category, tools);
88
+ }
89
+
90
+ const lines = [
91
+ "# Chrome Extension Tool Signatures",
92
+ "",
93
+ `Generated from extension capability contract \`${manifest.contractVersion}\`.`,
94
+ ];
95
+ for (const category of [...categories.keys()].sort(compareText)) {
96
+ lines.push("", `## ${category}`);
97
+ for (const tool of (categories.get(category) ?? []).sort((left, right) => compareText(left.name, right.name))) {
98
+ lines.push(
99
+ "",
100
+ `### \`${tool.name}\``,
101
+ "",
102
+ tool.summary,
103
+ "",
104
+ `- Parameters: \`${canonicalJson(tool.params)}\``,
105
+ `- Semantic flags: \`${canonicalJson(tool.flags ?? {})}\``,
106
+ );
107
+ }
108
+ }
109
+ return `${lines.join("\n")}\n`;
110
+ }
package/src/cli/args.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
 
18
18
  export type Mode = "text" | "json" | "rpc" | "acp";
19
19
 
20
+ export type ExtensionFlagRegistry = ReadonlyMap<string, { type: "boolean" | "string" }>;
21
+
20
22
  export interface Args {
21
23
  cwd?: string;
22
24
  allowHome?: boolean;
@@ -224,7 +226,97 @@ function isValueToken(token: string | undefined): token is string {
224
226
  return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
225
227
  }
226
228
 
227
- export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
229
+ export interface LaunchBootstrapArgs {
230
+ allowHome?: boolean;
231
+ cwd?: string;
232
+ noSandbox?: boolean;
233
+ allowPath: string[];
234
+ noMemories?: boolean;
235
+ hooks: string[];
236
+ extensions: string[];
237
+ noExtensions?: boolean;
238
+ pluginDirs: string[];
239
+ preExtensionExit: boolean;
240
+ }
241
+
242
+ const BOOTSTRAP_VALUE_FLAGS = new Set(["allow-path", "hook", "extension", "plugin-dir"]);
243
+ const PRE_EXTENSION_EXITS = new Set(["version", "list-models", "export"]);
244
+
245
+ /**
246
+ * Read only the built-in controls needed to load extensions.
247
+ *
248
+ * This is deliberately not an argument parse: it never classifies positional input, files, or
249
+ * extension flags. It only follows the known built-in grammar far enough to avoid mistaking a
250
+ * built-in value for a discovery control. The authoritative parse happens after this scan.
251
+ */
252
+ export function scanLaunchBootstrapArgs(args: readonly string[]): LaunchBootstrapArgs {
253
+ const result: LaunchBootstrapArgs = {
254
+ allowPath: [],
255
+ hooks: [],
256
+ extensions: [],
257
+ pluginDirs: [],
258
+ preExtensionExit: false,
259
+ };
260
+ const tokens = normalizeFlagTokens(args);
261
+
262
+ for (let i = 0; i < tokens.length; i++) {
263
+ const token = tokens[i];
264
+ if (token === "--") break;
265
+ if (!token.startsWith("-") || token === "-") continue;
266
+
267
+ const name = token.startsWith("--") ? token.slice(2) : flagNameForChar(token.slice(1));
268
+ if (name === undefined) continue;
269
+ const spec = flagSpec(name);
270
+ if (!spec) continue;
271
+
272
+ let value: string | true = true;
273
+ if (spec.arity === "optional-value") {
274
+ if (isValueToken(tokens[i + 1])) value = tokens[++i];
275
+ } else if (takesValue(spec)) {
276
+ if (tokens[i + 1] === undefined) continue;
277
+ value = tokens[++i];
278
+ }
279
+
280
+ if (PRE_EXTENSION_EXITS.has(name)) result.preExtensionExit = true;
281
+ if (name === "allow-home") result.allowHome = true;
282
+ if (name === "no-sandbox") result.noSandbox = true;
283
+ if (name === "no-memories") result.noMemories = true;
284
+ if (name === "no-extensions") result.noExtensions = true;
285
+ if (BOOTSTRAP_VALUE_FLAGS.has(name) && value !== true) {
286
+ if (name === "allow-path") result.allowPath.push(value);
287
+ if (name === "hook") result.hooks.push(value);
288
+ if (name === "extension") result.extensions.push(value);
289
+ if (name === "plugin-dir") result.pluginDirs.push(value);
290
+ }
291
+ }
292
+
293
+ return result;
294
+ }
295
+
296
+ export interface ResolvedLaunchArgs {
297
+ bootstrap: LaunchBootstrapArgs;
298
+ parsed: Args;
299
+ extensionFlags?: ExtensionFlagRegistry;
300
+ }
301
+
302
+ /**
303
+ * Resolve extension registrations before the one parse whose result drives launch behavior.
304
+ * Version/model-list/export retain their pre-extension fast path.
305
+ */
306
+ export async function resolveLaunchArgs(
307
+ args: readonly string[],
308
+ loadExtensionFlags: (bootstrap: LaunchBootstrapArgs) => Promise<ExtensionFlagRegistry>,
309
+ ): Promise<ResolvedLaunchArgs> {
310
+ const bootstrap = scanLaunchBootstrapArgs(args);
311
+ const extensionFlags = bootstrap.preExtensionExit ? undefined : await loadExtensionFlags(bootstrap);
312
+ return {
313
+ bootstrap,
314
+ parsed: parseArgs([...args], extensionFlags),
315
+ ...(extensionFlags ? { extensionFlags } : {}),
316
+ };
317
+ }
318
+
319
+ export function parseArgs(args: string[], extensionFlags?: ExtensionFlagRegistry): Args {
228
320
  const result: Args = {
229
321
  messages: [],
230
322
  fileArgs: [],
@@ -282,23 +374,20 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
282
374
  continue;
283
375
  }
284
376
 
285
- // Extension flags are only known on the second parse, once extensions have loaded.
377
+ // Extension registrations are loaded before this authoritative parse.
286
378
  const extFlag = name === undefined ? undefined : extensionFlags?.get(name);
287
379
  if (extFlag && name !== undefined) {
288
380
  if (extFlag.type === "boolean") {
289
- result.unknownFlags.set(name, true);
381
+ const inlineValue = token.startsWith("--") ? token.split("=", 2)[1] : undefined;
382
+ result.unknownFlags.set(name, inlineValue === undefined ? true : inlineValue === "true");
290
383
  } else if (i + 1 < tokens.length) {
291
384
  result.unknownFlags.set(name, tokens[++i]);
292
385
  }
293
386
  continue;
294
387
  }
295
388
 
296
- // Record the flag, but do NOT consume the token after it. The bootstrap parse runs before
297
- // extensions load, so it cannot know whether an unrecognized flag takes a value: swallowing
298
- // the next token silently discards the user's prompt whenever the flag turns out to be
299
- // boolean (`xcsh -p --verbose "do work"`). Leaving it means a string extension flag's value
300
- // still reaches `messages`, which is the pre-existing behaviour and the lesser harm — the
301
- // real fix is to load extensions before the first parse, which is out of scope here.
389
+ // Do not consume a following token for a genuine unknown flag: it may be prompt content.
390
+ // Registered extension flags never reach this path because launch discovers them first.
302
391
  result.unrecognizedFlags.push({ token, name: name ?? token.replace(/^-+/, "") });
303
392
  }
304
393
 
@@ -175,20 +175,23 @@ export function validateInlineFlagSyntax(args: readonly string[], extensionFlags
175
175
  if (!inline) continue;
176
176
 
177
177
  const spec = flagSpec(inline.name);
178
- const isBoolean = spec?.arity === "boolean" || extensionFlags?.get(inline.name)?.type === "boolean";
179
- if (isBoolean) {
178
+ if (spec?.arity === "boolean") {
180
179
  throw new CliUsageError(`--${inline.name} is a boolean flag and does not take a value`);
181
180
  }
181
+ if (extensionFlags?.get(inline.name)?.type === "boolean" && inline.value !== "true" && inline.value !== "false") {
182
+ throw new CliUsageError(`--${inline.name} is a boolean flag and expects true or false`);
183
+ }
182
184
  }
183
185
  }
184
186
 
185
187
  /**
186
188
  * Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
187
189
  *
188
- * A boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
190
+ * A built-in boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
189
191
  * `--no-sandbox=false`, which the parser has no way to express, and quietly reading it as "on" would
190
192
  * be exactly the class of bug #2469 reports. Unknown names are left intact so the unknown-flag path
191
193
  * can report the token as the user wrote it.
194
+ * Extension boolean flags accept explicit true or false values through their runtime map.
192
195
  *
193
196
  * Short forms are untouched: no shell convention makes `-p=x` mean `-p x`.
194
197
  */
@@ -223,7 +226,11 @@ export function normalizeFlagTokens(args: readonly string[], extensionFlags?: Ex
223
226
 
224
227
  const extension = extensionFlags?.get(name);
225
228
  if (extension) {
226
- normalized.push(`--${name}`, value);
229
+ if (extension.type === "boolean") {
230
+ normalized.push(arg);
231
+ } else {
232
+ normalized.push(`--${name}`, value);
233
+ }
227
234
  continue;
228
235
  }
229
236
 
@@ -425,6 +425,17 @@ async function handleInstall(
425
425
  }
426
426
 
427
427
  if (target.type === "marketplace") {
428
+ if (flags.dryRun) {
429
+ const preview = {
430
+ action: "install",
431
+ target: `${target.name}@${target.marketplace}`,
432
+ scope: flags.scope ?? "user",
433
+ dryRun: true,
434
+ };
435
+ if (flags.json) console.log(JSON.stringify(preview, null, 2));
436
+ else console.log(chalk.dim(`[dry-run] Would install ${preview.target} (${preview.scope})`));
437
+ continue;
438
+ }
428
439
  try {
429
440
  const entry = await mktMgr.installPlugin(target.name, target.marketplace, {
430
441
  force: flags.force,
@@ -6,10 +6,17 @@ import { executeShell, fencePermits } from "@f5-sales-demo/pi-natives";
6
6
  import { isEnoent } from "@f5-sales-demo/pi-utils";
7
7
  import { Settings } from "../config/settings";
8
8
  import { fenceForNative } from "../exec/bash-executor";
9
- import { buildContainmentFence, type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
9
+ import {
10
+ buildContainmentFence,
11
+ type ContainmentFence,
12
+ containmentStatus,
13
+ fenceVerdict,
14
+ seatbeltFenceVerdict,
15
+ } from "../sandbox/containment";
10
16
  import { evaluateToolCall } from "../sandbox/enforce";
11
17
  import {
12
18
  SANDBOX_CHECK_NAMED_SIBLING_ENV,
19
+ SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV,
13
20
  SANDBOX_OPERATOR_HOME_ENV,
14
21
  SANDBOX_SESSION_ROOT_ENV,
15
22
  sandboxCheckSiblingRoot,
@@ -74,6 +81,13 @@ function sanitizeDetail(value: string, redactions: readonly Redaction[]): string
74
81
  return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
75
82
  }
76
83
 
84
+ function inheritedNamedSiblingDenied(value: string | undefined): boolean | undefined {
85
+ if (value === undefined) return undefined;
86
+ if (value === "denied") return true;
87
+ if (value === "allowed") return false;
88
+ throw new Error(`invalid ${SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV}: expected "allowed" or "denied"`);
89
+ }
90
+
77
91
  function errnoFromOutput(output: string): string {
78
92
  if (/operation not permitted/iu.test(output)) return "EPERM";
79
93
  if (/permission denied/iu.test(output)) return "EACCES";
@@ -210,6 +224,10 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
210
224
  const inheritedHome = process.env[SANDBOX_OPERATOR_HOME_ENV];
211
225
  const inheritedSibling = process.env[SANDBOX_CHECK_NAMED_SIBLING_ENV];
212
226
  const inheritedProfile = inheritedWorkspace !== undefined;
227
+ const inheritedSiblingDenied =
228
+ inheritedProfile && inheritedSibling !== undefined
229
+ ? inheritedNamedSiblingDenied(process.env[SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV])
230
+ : undefined;
213
231
  const workspaceInput = inheritedWorkspace ?? process.cwd();
214
232
  const homeInput = inheritedHome ?? os.homedir();
215
233
  redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
@@ -462,7 +480,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
462
480
  );
463
481
  });
464
482
 
465
- await check("named sibling remains reachable", async () => {
483
+ await check("named sibling follows the active boundary", async () => {
466
484
  const displayPath = "<session-parent>/<synthetic-sibling>";
467
485
  let liveSibling = inheritedSibling;
468
486
  if (liveSibling === undefined) {
@@ -480,13 +498,24 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
480
498
  return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
481
499
  }
482
500
  }
501
+ const seatbeltDeniesSibling =
502
+ inheritedSiblingDenied ??
503
+ (backend.backend === "seatbelt" && seatbeltFenceVerdict(liveFence, liveSibling, "read") === "deny");
483
504
  const result = await shellProbe(
484
- 'test "$(cat named.txt)" = sibling',
485
- liveSibling,
486
- undefined,
505
+ `cd ${quote(liveSibling)} && test "$(cat named.txt)" = sibling`,
506
+ liveWorkspace,
507
+ inheritedProfile ? undefined : liveFence,
487
508
  abortController.signal,
488
509
  );
489
- return shellOutcome(result, true, "live profile must allow a named sibling read", displayPath, redactions);
510
+ return shellOutcome(
511
+ result,
512
+ !seatbeltDeniesSibling,
513
+ seatbeltDeniesSibling
514
+ ? "Seatbelt must deny a named sibling outside the workspace"
515
+ : "live profile must allow a named sibling read",
516
+ displayPath,
517
+ redactions,
518
+ );
490
519
  });
491
520
 
492
521
  if (backend.osEnforced) {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { APP_NAME } from "@f5-sales-demo/pi-utils";
6
6
  import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
7
- import { parseArgs } from "../cli/args";
8
7
  import { buildCliFlags } from "../cli/flag-spec";
9
8
  import { runRootCommand } from "../main";
10
9
 
@@ -36,7 +35,6 @@ export default class Index extends Command {
36
35
  static strict = false;
37
36
 
38
37
  async run(): Promise<void> {
39
- const parsed = parseArgs(this.argv);
40
- await runRootCommand(parsed, this.argv);
38
+ await runRootCommand(this.argv);
41
39
  }
42
40
  }
@@ -187,6 +187,8 @@ const ThinkingControlModeSchema = Type.Union([
187
187
  const ModelThinkingSchema = Type.Object({
188
188
  minLevel: EffortSchema,
189
189
  maxLevel: EffortSchema,
190
+ defaultLevel: Type.Optional(EffortSchema),
191
+ canDisable: Type.Optional(Type.Boolean()),
190
192
  mode: ThinkingControlModeSchema,
191
193
  });
192
194
 
@@ -843,12 +843,13 @@ export async function listXcshPluginRoots(
843
843
  roots.push(...projectRoots, ...deduped);
844
844
  }
845
845
 
846
- // Merge --plugin-dir roots (highest precedence) on every fresh load
846
+ // Merge --plugin-dir roots (highest precedence) on every fresh load. Local roots use
847
+ // an artificial marketplace ID, so identity must be matched by manifest plugin name;
848
+ // comparing full IDs would leave the installed copy active beside the candidate.
847
849
  if (injectedPluginDirRoots.length > 0) {
848
- const injectedIds = new Set(injectedPluginDirRoots.map(r => r.id));
849
- const filtered = roots.filter(r => !injectedIds.has(r.id));
850
+ const merged = prioritizeInjectedPluginRoots(roots, injectedPluginDirRoots);
850
851
  roots.length = 0;
851
- roots.push(...injectedPluginDirRoots, ...filtered);
852
+ roots.push(...merged);
852
853
  }
853
854
 
854
855
  const result = { roots, warnings };
@@ -856,6 +857,19 @@ export async function listXcshPluginRoots(
856
857
  return result;
857
858
  }
858
859
 
860
+ export function prioritizeInjectedPluginRoots(
861
+ installed: XcshPluginRoot[],
862
+ injected: XcshPluginRoot[],
863
+ ): XcshPluginRoot[] {
864
+ const seen = new Set<string>();
865
+ const winners = injected.filter(root => {
866
+ if (seen.has(root.plugin)) return false;
867
+ seen.add(root.plugin);
868
+ return true;
869
+ });
870
+ return [...winners, ...installed.filter(root => !seen.has(root.plugin))];
871
+ }
872
+
859
873
  export interface XcshPluginSummary {
860
874
  /** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
861
875
  id: string;
@@ -24,6 +24,7 @@ export function fenceForNative(fence: ContainmentFence | undefined) {
24
24
  allowReadOnly: [...fence.allowReadOnly],
25
25
  allowWriteOnly: [...fence.allowWriteOnly],
26
26
  deny: [...fence.deny],
27
+ denyOnSeatbelt: [...fence.denyOnSeatbelt],
27
28
  denyEnumerate: [...fence.denyEnumerate],
28
29
  };
29
30
  }
@@ -171,7 +171,7 @@ function renderContainment(containment: ContainmentStatus | null): string {
171
171
  if (!containment) return "";
172
172
  // `landlock` is derived rather than another field on the status, because the template needs a
173
173
  // boolean and Handlebars cannot compare strings. It gates the Linux-only costs — unlistable split
174
- // directories, no setuid, no interactive terminal — which are true of that backend and no other.
174
+ // directories and no setuid — which are true of that backend and no other.
175
175
  return prompt.render(containmentTemplate, {
176
176
  containment: { ...containment, landlock: containment.backend === "landlock" },
177
177
  });
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "21.0.0",
21
- "commit": "7402d0fe1d43a25000908463ed254d54c71d0b6b",
22
- "shortCommit": "7402d0f",
20
+ "version": "21.2.0",
21
+ "commit": "d217f62f68fae54fe18d88c7d90a8f7dc4703c0d",
22
+ "shortCommit": "d217f62",
23
23
  "branch": "main",
24
- "tag": "v21.0.0",
25
- "commitDate": "2026-08-28T10:34:07+00:00",
26
- "buildDate": "2026-08-28T11:01:38.586Z",
24
+ "tag": "v21.2.0",
25
+ "commitDate": "2026-08-30T03:18:04+00:00",
26
+ "buildDate": "2026-08-30T03:46:25.618Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/7402d0fe1d43a25000908463ed254d54c71d0b6b",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.0.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/d217f62f68fae54fe18d88c7d90a8f7dc4703c0d",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.2.0"
33
33
  };
@@ -121,10 +121,10 @@ function renderResource(resource: string, catalog: ConsoleCatalogData, fieldMeta
121
121
  const doc = (parseYaml(raw) ?? {}) as Record<string, unknown>;
122
122
  const console_ = (doc.console ?? {}) as Record<string, unknown>;
123
123
  const lines = [`# ${(doc.label as string | undefined) ?? key}`, ""];
124
- if (console_.route_pattern) {
125
- const fullRoute = console_.route_prefix
126
- ? `${console_.route_prefix}${console_.route_pattern}`
127
- : console_.route_pattern;
124
+ const routePattern = typeof console_.route_pattern === "string" ? console_.route_pattern : undefined;
125
+ if (routePattern) {
126
+ const routePrefix = typeof console_.route_prefix === "string" ? console_.route_prefix : undefined;
127
+ const fullRoute = routePrefix ? `${routePrefix}${routePattern}` : routePattern;
128
128
  lines.push(`**Route:** \`${fullRoute}\``, "");
129
129
  }
130
130
  if (Array.isArray(console_.menu_path)) lines.push(`**Menu:** ${(console_.menu_path as string[]).join(" › ")}`, "");
@@ -155,8 +155,9 @@ function renderWorkflow(resource: string, operation: string, catalog: ConsoleCat
155
155
  for (const s of steps) {
156
156
  const sel = s.selector ? ` \`${s.selector}\`` : "";
157
157
  const val = s.value != null ? ` = ${JSON.stringify(s.value)}` : "";
158
+ const action = typeof s.action === "string" ? s.action : "unknown";
158
159
  lines.push(
159
- `1. **${s.action}**${sel}${val} — ${(s.description as string | undefined) ?? (s.id as string | undefined) ?? ""}`,
160
+ `1. **${action}**${sel}${val} — ${(s.description as string | undefined) ?? (s.id as string | undefined) ?? ""}`,
160
161
  );
161
162
  }
162
163
  return `${lines.join("\n")}\n`;
@@ -0,0 +1,4 @@
1
+ // Auto-generated by scripts/generate-extension-capabilities.ts - DO NOT EDIT
2
+ // Source: src/browser/capabilities.json (the Chrome extension's published contract).
3
+
4
+ export const EXTENSION_TOOL_REFERENCE = "# Chrome Extension Tool Signatures\n\nGenerated from extension capability contract `2.1.0`.\n\n## annotation\n\n### `annotate`\n\nDraw an overlay annotation (fingerprint/highlight). No-op unless explain mode is on.\n\n- Parameters: `{\"properties\":{\"h\":{\"type\":\"number\"},\"kind\":{\"type\":\"string\"},\"ref\":{\"type\":\"string\"},\"w\":{\"type\":\"number\"},\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"kind\"],\"type\":\"object\"}`\n- Semantic flags: `{\"requiresExplainMode\":true}`\n\n### `set_explain_mode`\n\nEnter/leave explain mode — the gate for all on-page annotation overlays.\n\n- Parameters: `{\"properties\":{\"enabled\":{\"type\":\"boolean\"}},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n## interaction\n\n### `click`\n\nDeterministic click of an AX-ref element (layout-engine coords + hit-test).\n\n- Parameters: `{\"properties\":{\"ref\":{\"type\":\"string\"}},\"required\":[\"ref\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `click_element`\n\nClick the element returned by a JS expression (polls; occlusion-safe).\n\n- Parameters: `{\"properties\":{\"js\":{\"type\":\"string\"},\"wait_ms\":{\"type\":\"number\"}},\"required\":[\"js\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `click_xy`\n\nTrusted click at explicit viewport coordinates.\n\n- Parameters: `{\"properties\":{\"x\":{\"type\":\"number\"},\"y\":{\"type\":\"number\"}},\"required\":[\"x\",\"y\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `file_upload`\n\nUpload files (base64 data URIs) to a file input by AX ref.\n\n- Parameters: `{\"properties\":{\"files\":{\"items\":{\"type\":\"string\"},\"type\":\"array\"},\"ref\":{\"type\":\"string\"}},\"required\":[\"ref\",\"files\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `form_input`\n\nSet a form field value by AX ref.\n\n- Parameters: `{\"properties\":{\"ref\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `key_press`\n\nDispatch a key press.\n\n- Parameters: `{\"properties\":{\"key\":{\"type\":\"string\"}},\"required\":[\"key\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `label_select`\n\nType into a CDK-portal typeahead and click the matching option.\n\n- Parameters: `{\"properties\":{\"label_value\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"},\"wait_ms\":{\"type\":\"number\"}},\"required\":[\"selector\",\"value\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `select_option`\n\nSelect an option in a native <select> by AX ref.\n\n- Parameters: `{\"properties\":{\"ref\":{\"type\":\"string\"},\"value\":{\"type\":\"string\"}},\"required\":[\"ref\",\"value\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `type_text`\n\nType text into the focused element (trusted input).\n\n- Parameters: `{\"properties\":{\"text\":{\"type\":\"string\"}},\"required\":[\"text\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n## meta\n\n### `capabilities`\n\nReturn this self-describing capability manifest (tools + features + versions).\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `debug_exec`\n\nDiagnostic: probe in-page bridge availability.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `detach`\n\nDetach the debugger from the target tab.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `ping`\n\nLiveness check; returns { ok, version }.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `reload`\n\nReload the extension (re-reads dist/ from disk).\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `set_bridge_port`\n\nSet the WebSocket bridge port (persists across reload; enables multi-session on different ports).\n\n- Parameters: `{\"properties\":{\"port\":{\"type\":\"number\"}},\"required\":[\"port\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n## navigation\n\n### `navigate`\n\nNavigate the console tab to a scoped https URL.\n\n- Parameters: `{\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `resize_window`\n\nResize the browser window.\n\n- Parameters: `{\"properties\":{\"height\":{\"type\":\"number\"},\"width\":{\"type\":\"number\"}},\"required\":[\"width\",\"height\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `scroll_to`\n\nScroll an AX-ref element into view.\n\n- Parameters: `{\"properties\":{\"ref\":{\"type\":\"string\"}},\"required\":[\"ref\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `tabs_close`\n\nClose a tab by id.\n\n- Parameters: `{\"properties\":{\"tabId\":{\"type\":\"number\"}},\"required\":[\"tabId\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `tabs_create`\n\nOpen a new tab at a scoped URL.\n\n- Parameters: `{\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `tabs_list`\n\nList scoped console tabs.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n## read\n\n### `assert_text`\n\nAssert an element contains expected text.\n\n- Parameters: `{\"properties\":{\"context\":{\"type\":\"string\"},\"expected\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\",\"expected\"],\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `diag_activation`\n\nDiagnostic: per-gate tab-activation readiness timings (bridge/worker/page), cold/warm.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n### `diag_bridges`\n\nList discovered xcsh bridge health without tenant, environment, or session identifiers.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n### `diag_suspension`\n\nDiagnostic: SW-lifecycle event buffer + suspension summary (Phase 0a).\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n### `diag_ttft`\n\nDiagnostic: init→first-token timeline (per-stage ms, total, dominant, cold/warm).\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{}`\n\n### `find`\n\nFind AX nodes matching a locator.\n\n- Parameters: `{\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"],\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `get_page_context`\n\nReturn a snapshot of the active console page (url, AX tree, captured XC API body) for chat grounding.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `get_page_text`\n\nReturn the page text.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `query_dom`\n\nDirect DOM.querySelector at wire speed — bypasses Runtime.evaluate for simple CSS selectors.\n\n- Parameters: `{\"properties\":{\"selector\":{\"type\":\"string\"}},\"required\":[\"selector\"],\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `read_ax`\n\nRead the accessibility tree of the page.\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `read_console`\n\nRead buffered console messages, optionally filtered by pattern.\n\n- Parameters: `{\"properties\":{\"pattern\":{\"type\":\"string\"}},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `read_network`\n\nRead buffered network events, optionally filtered by pattern.\n\n- Parameters: `{\"properties\":{\"pattern\":{\"type\":\"string\"}},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `screenshot`\n\nCapture a screenshot (base64 PNG).\n\n- Parameters: `{\"properties\":{},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `wait_for`\n\nWait for an AX node matching a locator to appear.\n\n- Parameters: `{\"properties\":{\"context\":{\"type\":\"string\"},\"selector\":{\"type\":\"string\"},\"timeoutMs\":{\"type\":\"number\"}},\"required\":[\"selector\"],\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n### `wait_for_api_response`\n\nWait for a network response whose URL matches a pattern.\n\n- Parameters: `{\"properties\":{\"pattern\":{\"type\":\"string\"},\"timeout_ms\":{\"type\":\"number\"}},\"type\":\"object\"}`\n- Semantic flags: `{\"readOnly\":true}`\n\n## script\n\n### `browser_batch`\n\nRun a batch of { tool, params } actions in sequence.\n\n- Parameters: `{\"properties\":{\"actions\":{\"items\":{\"properties\":{\"params\":{},\"tool\":{\"type\":\"string\"}},\"required\":[\"tool\"],\"type\":\"object\"},\"type\":\"array\"}},\"required\":[\"actions\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n\n### `javascript_tool`\n\nEvaluate arbitrary JS in the page (length-capped).\n\n- Parameters: `{\"properties\":{\"code\":{\"type\":\"string\"}},\"required\":[\"code\"],\"type\":\"object\"}`\n- Semantic flags: `{\"mutates\":true}`\n";