@f5-sales-demo/xcsh 19.87.1 → 19.89.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": "19.87.1",
4
+ "version": "19.89.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",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.87.1",
60
- "@f5-sales-demo/pi-agent-core": "19.87.1",
61
- "@f5-sales-demo/pi-ai": "19.87.1",
62
- "@f5-sales-demo/pi-natives": "19.87.1",
63
- "@f5-sales-demo/pi-resource-management": "19.87.1",
64
- "@f5-sales-demo/pi-tui": "19.87.1",
65
- "@f5-sales-demo/pi-utils": "19.87.1",
59
+ "@f5-sales-demo/xcsh-stats": "19.89.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.89.0",
61
+ "@f5-sales-demo/pi-ai": "19.89.0",
62
+ "@f5-sales-demo/pi-natives": "19.89.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.89.0",
64
+ "@f5-sales-demo/pi-tui": "19.89.0",
65
+ "@f5-sales-demo/pi-utils": "19.89.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -1,4 +1,7 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { AssistantMessage, ImageContent } from "@f5-sales-demo/pi-ai";
4
+ import { settings } from "../config/settings";
2
5
  import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
3
6
  import {
4
7
  isRpcHostToolResult,
@@ -24,11 +27,13 @@ import {
24
27
  isChatRequest,
25
28
  isChatStop,
26
29
  isConfigure,
30
+ isListSkills,
27
31
  isSetHostTools,
28
32
  type PageContextSnapshot,
29
33
  type SetHostTools,
30
34
  type SetHostToolsAck,
31
35
  type SetHostToolsError,
36
+ type SkillsList,
32
37
  } from "./chat-protocol";
33
38
  import { CONSOLE_ROUTES } from "./console-routes.generated";
34
39
  import type { BridgeServer } from "./extension-bridge";
@@ -101,6 +106,9 @@ export class ChatHandler {
101
106
  // Provider configuration channel (#2095): swap the LLM provider/model in
102
107
  // session memory at runtime (never persisted), then ack or nack.
103
108
  else if (isConfigure(msg)) this.#handleConfigure(msg as unknown as Configure);
109
+ // Skills enumeration (#2311): the pane asks for the loaded skills to populate
110
+ // the composer's Skills submenu.
111
+ else if (isListSkills(msg)) this.#handleListSkills();
104
112
  else if (isRpcHostToolResult(msg)) this.#hostToolBridge.handleResult(msg as unknown as HostToolResult);
105
113
  else if (isRpcHostToolUpdate(msg)) this.#hostToolBridge.handleUpdate(msg as unknown as HostToolUpdate);
106
114
  });
@@ -164,7 +172,13 @@ export class ChatHandler {
164
172
  });
165
173
  chat.unsubscribe = unsubscribe;
166
174
 
167
- const prompt = composeChatPrompt(req.text, req.context, req.mode, this.#server.clientHost);
175
+ // Sanitize the attached paths (absolute, `..`-collapsed, confined to the user's
176
+ // own space, no control chars) — the engine must not blindly widen its sandbox to
177
+ // a client-supplied path. Grant the safe set BEFORE composing the prompt so the
178
+ // model's read/grep/bash calls pass the gate. Session-scoped, in-memory, deduped.
179
+ const contextPaths = Array.isArray(req.contextPaths) ? sanitizeContextPaths(req.contextPaths) : [];
180
+ if (contextPaths.length > 0) grantSandboxPaths(contextPaths);
181
+ const prompt = composeChatPrompt(req.text, req.context, req.mode, this.#server.clientHost, contextPaths);
168
182
  // Photo/image attachments ride as base64 vision blocks (the model is
169
183
  // vision-capable); text attachments are already folded into req.text upstream.
170
184
  const images: ImageContent[] | undefined = req.images?.map(img => ({
@@ -172,10 +186,16 @@ export class ChatHandler {
172
186
  data: img.data,
173
187
  mimeType: img.mimeType,
174
188
  }));
189
+ // "Search the web" toggle → add Anthropic's server-side web-search tool for this
190
+ // turn only. The gateway executes it and returns cited results; source URLs the
191
+ // model writes inline flow to the pane's Sources chips via extractReferences.
192
+ const serverTools = req.web_search
193
+ ? [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }]
194
+ : undefined;
175
195
 
176
196
  try {
177
197
  chat.promptAt = Date.now();
178
- await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images });
198
+ await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images, serverTools });
179
199
  } catch (err: unknown) {
180
200
  const message = err instanceof Error ? err.message : "unknown error";
181
201
  this.#sendTerminal(chat, { type: "chat_error", id, error: message, reason: classifyChatErrorReason(message) });
@@ -377,6 +397,15 @@ export class ChatHandler {
377
397
  this.#session.agent.abort();
378
398
  }
379
399
 
400
+ /** Reply to `list_skills` with the session's live skills (name + description) so
401
+ * the pane can populate the composer's Skills submenu. Pure read — skills are
402
+ * already loaded on the session; the model actions them via the read tool + the
403
+ * system prompt (Phase 2A enabled `read`), so this is enumeration only. */
404
+ #handleListSkills(): void {
405
+ const skills = this.#session.skills.map(s => ({ name: s.name, description: s.description }));
406
+ this.#server.send({ type: "skills", skills } satisfies SkillsList);
407
+ }
408
+
380
409
  #sendTerminal(chat: ActiveChat, frame: ChatDone | ChatError): void {
381
410
  if (chat.terminalSent) return;
382
411
  chat.terminalSent = true;
@@ -455,11 +484,79 @@ const MODE_INSTRUCTIONS: Record<InteractionMode, string> = {
455
484
  annotation: "Create on-page teaching annotations that highlight key elements and explain their purpose.",
456
485
  };
457
486
 
487
+ /** Bases a user-attached context path must fall under. Confines grants to the user's
488
+ * own space (home, the project cwd, temp, external volumes, /opt) and thereby blocks
489
+ * a client from widening the sandbox to system/credential dirs (`/etc`, `/var`,
490
+ * `/usr`, `/System`, other users' homes, `/`). */
491
+ function contextPathAllowedBases(): string[] {
492
+ const bases = [process.cwd(), "/tmp", "/private/tmp", "/Volumes", "/opt"];
493
+ const home = process.env.HOME ?? process.env.USERPROFILE;
494
+ if (home) bases.push(home);
495
+ // Canonicalize with realpath so BOTH sides of the containment check are symlink-free
496
+ // (a symlink base like /tmp→/private/tmp must compare against the real target).
497
+ return bases.map(b => {
498
+ try {
499
+ return fs.realpathSync(b);
500
+ } catch {
501
+ return path.resolve(b);
502
+ }
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Canonicalize + validate the user-attached context paths BEFORE they widen the
508
+ * sandbox or are named in the prompt. Rejects anything that isn't an absolute path
509
+ * confined to {@link contextPathAllowedBases} (after collapsing `..`), and anything
510
+ * carrying control characters (a prompt-injection vector). Deduped. This is the trust
511
+ * boundary: the engine must not blindly grant a client-supplied path to its own
512
+ * filesystem sandbox.
513
+ */
514
+ export function sanitizeContextPaths(paths: string[]): string[] {
515
+ const bases = contextPathAllowedBases();
516
+ const seen = new Set<string>();
517
+ const out: string[] = [];
518
+ for (const p of paths) {
519
+ if (typeof p !== "string" || p.length === 0 || /[\n\r\0]/.test(p) || !path.isAbsolute(p)) continue;
520
+ // realpathSync resolves symlinks — a link INSIDE an allowed base that points out to
521
+ // /etc canonicalizes to /etc and fails the containment check — AND requires the
522
+ // path to exist, so a non-existent (typo'd/spoofed) attachment is skipped.
523
+ let real: string;
524
+ try {
525
+ real = fs.realpathSync(p);
526
+ } catch {
527
+ continue;
528
+ }
529
+ const within = bases.some(base => real === base || real.startsWith(base + path.sep));
530
+ if (!within || seen.has(real)) continue;
531
+ seen.add(real);
532
+ out.push(real);
533
+ }
534
+ return out;
535
+ }
536
+
537
+ /**
538
+ * Append absolute context paths to the session's sandbox read-allowlist. In-memory
539
+ * (`override`, never persisted) and deduped, so re-attaching a path is a no-op. The
540
+ * sandbox-guard keys its policy cache on the allow-list, so the grant takes effect on
541
+ * the model's next file tool call. Best-effort: a pre-init settings proxy is tolerated.
542
+ * Callers MUST pass paths through {@link sanitizeContextPaths} first.
543
+ */
544
+ export function grantSandboxPaths(paths: string[]): void {
545
+ try {
546
+ const current = (settings.get("sandbox.allowRead") as string[] | undefined) ?? [];
547
+ const merged = Array.from(new Set([...current, ...paths]));
548
+ if (merged.length !== current.length) settings.override("sandbox.allowRead", merged);
549
+ } catch {
550
+ /* settings not initialized (some SDK/test contexts) — the grant is best-effort */
551
+ }
552
+ }
553
+
458
554
  export function composeChatPrompt(
459
555
  text: string,
460
556
  context: PageContextSnapshot | null,
461
557
  mode: InteractionMode,
462
558
  host: ClientHost | null,
559
+ contextPaths?: string[],
463
560
  ): string {
464
561
  const parts: string[] = [];
465
562
 
@@ -477,6 +574,17 @@ export function composeChatPrompt(
477
574
  if (context) composeBrowserPageContext(parts, context);
478
575
  }
479
576
 
577
+ // User-attached local context paths (files/folders). They're granted to the
578
+ // sandbox alongside this, so the model may read them on demand — tell it they exist.
579
+ if (contextPaths && contextPaths.length > 0) {
580
+ parts.push("");
581
+ // JSON.stringify each path: an unambiguous, escaped boundary so a path can't
582
+ // blur into surrounding prompt text (belt-and-suspenders — they're pre-sanitized).
583
+ parts.push(
584
+ `The user attached these local paths as context; read them with your tools (read/grep/bash) as needed:\n${contextPaths.map(p => `- ${JSON.stringify(p)}`).join("\n")}`,
585
+ );
586
+ }
587
+
480
588
  parts.push("");
481
589
  parts.push(text);
482
590
  return parts.join("\n");
@@ -85,6 +85,48 @@ export interface ChatRequest {
85
85
  history_hint?: string;
86
86
  /** Optional photo/image attachments, sent to the model as vision blocks. */
87
87
  images?: ChatImage[];
88
+ /** Absolute local paths (files/folders) the user attached as context. The engine
89
+ * grants them to the filesystem sandbox for the session and tells the model they
90
+ * are available to read on demand. */
91
+ contextPaths?: string[];
92
+ /** When true, the engine adds Anthropic's server-side web-search tool to this
93
+ * turn's request (the "Search the web" composer toggle). */
94
+ web_search?: boolean;
95
+ }
96
+
97
+ /** Client → engine: open a native OS file/folder picker on the machine running the
98
+ * bridge and return the chosen absolute path. */
99
+ export interface PickPath {
100
+ type: "pick_path";
101
+ mode: "file" | "folder";
102
+ }
103
+
104
+ /** Engine → client: the picker result. `path` is set on success; `canceled` when the
105
+ * user dismissed the dialog; `unsupported` when the platform has no native picker
106
+ * (the pane then falls back to manual path entry). */
107
+ export interface PathPicked {
108
+ type: "path_picked";
109
+ path?: string;
110
+ canceled?: boolean;
111
+ unsupported?: boolean;
112
+ }
113
+
114
+ /** Client → engine: enumerate the session's loaded skills for the composer's
115
+ * Skills submenu. Sent once after the pane connects. */
116
+ export interface ListSkills {
117
+ type: "list_skills";
118
+ }
119
+
120
+ /** One skill surfaced to the pane's Skills submenu (name + human description). */
121
+ export interface SkillInfo {
122
+ name: string;
123
+ description: string;
124
+ }
125
+
126
+ /** Engine → client: the session's live skills, in load order. */
127
+ export interface SkillsList {
128
+ type: "skills";
129
+ skills: SkillInfo[];
88
130
  }
89
131
 
90
132
  export interface ChatStop {
@@ -266,6 +308,14 @@ export function isChatStop(msg: Record<string, unknown>): boolean {
266
308
  return msg.type === "chat_stop" && hasChatIdPrefix(msg.id);
267
309
  }
268
310
 
311
+ export function isListSkills(msg: Record<string, unknown>): boolean {
312
+ return msg.type === "list_skills";
313
+ }
314
+
315
+ export function isPickPath(msg: Record<string, unknown>): boolean {
316
+ return msg.type === "pick_path" && (msg.mode === "file" || msg.mode === "folder");
317
+ }
318
+
269
319
  export function isSetHostTools(msg: Record<string, unknown>): boolean {
270
320
  return msg.type === "set_host_tools" && Array.isArray(msg.tools);
271
321
  }
@@ -100,16 +100,48 @@ export const BROWSER_TOOL_NAMES: readonly string[] = [
100
100
 
101
101
  /**
102
102
  * Builtin agent tools scoped into the headless OFFICE bridge (`xcsh office serve`).
103
- * The Office task pane drives a document (Excel/PowerPoint/Word), NOT a browser, so
104
- * it must get NONE of the {@link BROWSER_TOOL_NAMES} — those would be hallucinated
105
- * against a host with no browser to drive. The document's own tools arrive at
106
- * runtime over the bridge via `set_host_tools`; this list is only the minimal
107
- * general-purpose, host-neutral builtin toolset a document assistant can safely use.
108
103
  *
109
- * NOTE: an EMPTY list cannot express "no builtin tools" createAgentSession/createTools
110
- * treat `[]` as "unscoped" and hand back the FULL builtin registry (bash/edit/python/
111
- * browser/…). So we pass an explicit minimal set instead. `calc` is the one builtin
112
- * that is pure computation — no browser, no shell, no filesystem, no network — and is
113
- * genuinely useful for spreadsheet/document math.
104
+ * FULL CLI-PARITY tool set (minus browser automation): the Office pane is a full
105
+ * local xcsh agent, so it gets the same general-purpose native tools the CLI has
106
+ * `bash` (so it can shell out to `az`, `gh`, terraform, git, …), the file tools
107
+ * (`read`/`write`/`edit`), search (`grep`), plus planning (`todo_write`,
108
+ * `task`) and `calc`. (File-finding is covered by `bash`; the builtin `find`
109
+ * tool is omitted because its name collides with a browser tool.) The document's own Excel/PowerPoint/Word tools arrive at
110
+ * runtime over the bridge via `set_host_tools`.
111
+ *
112
+ * DELIBERATELY EXCLUDED:
113
+ * - Every {@link BROWSER_TOOL_NAMES} entry — there is no browser to drive in a
114
+ * document pane, so navigate/click/screenshot would only be hallucinated.
115
+ * - `ask` (needs interactive stdin → would hang headless), `python` (spawns a
116
+ * kernel → startup cost), `ssh`/`debug`/`notebook`/`browser`/`get_page_context`.
117
+ *
118
+ * SAFETY: the headless session pairs this with the bundled `sandbox-guard`
119
+ * extension (see headless-bridge.ts `bundledExtensions`), which confines the file
120
+ * tools + the shell's working dir to the launch cwd subtree — the CLI's own model.
121
+ * `az`/`gh` still run (network actions aren't filesystem-confined); credentials must
122
+ * already exist for the process user. There is no per-tool approval prompt — the
123
+ * local trusted bridge auto-runs tools exactly as the CLI does.
124
+ *
125
+ * THREAT MODEL (reviewed + accepted, 2026-07-24): the pane's agent auto-reads
126
+ * document content, which could be adversarial (a prompt-injected customer .xlsx)
127
+ * and steer it into shell/`az`/`gh` calls; the filesystem sandbox blocks file
128
+ * damage outside cwd but NOT network/cloud actions. This is the same exposure the
129
+ * xcsh CLI already carries (no approval system anywhere). The operator explicitly
130
+ * chose full CLI parity + FS sandbox over a bash approval gate, mitigating in
131
+ * practice by only opening trusted documents. If untrusted files become common,
132
+ * revisit with a per-shell approval round-trip (host_tool_call-style frame).
133
+ *
134
+ * NOTE: an EMPTY list cannot express "no builtin tools" — createTools treats `[]` as
135
+ * "unscoped" and returns the FULL registry (including browser tools). So this is an
136
+ * explicit curated array, not `[]`.
114
137
  */
115
- export const OFFICE_TOOL_NAMES: readonly string[] = ["calc"];
138
+ export const OFFICE_TOOL_NAMES: readonly string[] = [
139
+ "read",
140
+ "write",
141
+ "edit",
142
+ "bash",
143
+ "grep",
144
+ "todo_write",
145
+ "task",
146
+ "calc",
147
+ ];
@@ -20,8 +20,10 @@ import { ContextService } from "../services/xcsh-context";
20
20
  import { deriveTenantEnv } from "../services/xcsh-env";
21
21
  import { resolveBridgeTls } from "./bridge-cert";
22
22
  import { ChatHandler } from "./chat-handler";
23
+ import { isPickPath, type PathPicked } from "./chat-protocol";
23
24
  import { type BridgeServer, OFFICE_PORT_RANGE, startBridgeServer } from "./extension-bridge";
24
25
  import { OFFICE_TOOL_NAMES } from "./extension-bridge-tools";
26
+ import { pickPathNative } from "./native-picker";
25
27
  import { setSharedBridgeServer } from "./provider";
26
28
 
27
29
  /** A running headless bridge + a teardown that disposes the chat handler and
@@ -69,6 +71,9 @@ export interface HeadlessBridgeDeps {
69
71
  setSharedBridgeServer: typeof setSharedBridgeServer;
70
72
  createAgentSession: typeof createAgentSession;
71
73
  ChatHandlerCtor: typeof ChatHandler;
74
+ /** Native OS file/folder picker (macOS osascript by default). Injectable so tests
75
+ * don't open a real dialog. */
76
+ pickPath: typeof pickPathNative;
72
77
  }
73
78
 
74
79
  const defaultDeps: HeadlessBridgeDeps = {
@@ -96,6 +101,7 @@ const defaultDeps: HeadlessBridgeDeps = {
96
101
  setSharedBridgeServer,
97
102
  createAgentSession,
98
103
  ChatHandlerCtor: ChatHandler,
104
+ pickPath: pickPathNative,
99
105
  };
100
106
 
101
107
  /**
@@ -134,12 +140,10 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
134
140
  // selectProvider() reuses a dead bridge. The caller (startOfficeServe) treats
135
141
  // the rethrow as a non-fatal "pane only" fallback.
136
142
  try {
137
- // Create ONE headless Office session scoped to the minimal general builtin set
138
- // (OFFICE_TOOL_NAMES) with NO browser tools — neither the browser BUILTINS
139
- // (navigate/click/…) nor the bridge-proxying browser CUSTOM tools, both of which
140
- // would be hallucinated in a document task pane (there is no browser to drive).
141
- // The document's own tools (Excel/Word/PowerPoint) arrive at runtime via
142
- // set_host_tools; chat over xcsh's configured provider needs no browser tooling.
143
+ // Create ONE headless Office session with the full CLI-parity builtin set
144
+ // (OFFICE_TOOL_NAMES: bash/read/write/edit/grep/find/… NO browser tools, which
145
+ // would be hallucinated in a document task pane). The document's own tools
146
+ // (Excel/Word/PowerPoint) arrive at runtime via set_host_tools.
143
147
  const { session } = await deps.createAgentSession({
144
148
  cwd,
145
149
  hasUI: false,
@@ -149,14 +153,33 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
149
153
  enableMCP: false,
150
154
  enableLsp: false,
151
155
  disableExtensionDiscovery: true,
156
+ // …but DO load the bundled filesystem sandbox: the pane runs full CLI-parity
157
+ // tools (bash/read/write), so it needs the CLI's safety net confining file
158
+ // tools + the shell's cwd to the launch directory subtree (sandbox.enabled
159
+ // defaults true). Without this, a discovery-disabled session ran ungated.
160
+ bundledExtensions: ["sandbox-guard"],
152
161
  });
153
162
 
154
163
  const chatHandler = new deps.ChatHandlerCtor(bridge, session);
155
164
  chatHandler.attach();
156
165
 
166
+ // Second bridge subscriber (the bridge fans out to all): serve `pick_path` by
167
+ // opening a native OS picker and replying `path_picked`. Pure — it only returns
168
+ // the chosen path; the sandbox grant + prompt note happen in ChatHandler when the
169
+ // path rides the next `chat_request.contextPaths` (kept atomic there). `disposed`
170
+ // guards against a superseded serve firing the picker on a closed bridge.
171
+ let disposed = false;
172
+ bridge.onMessage(async msg => {
173
+ if (disposed || !isPickPath(msg)) return;
174
+ const { path, canceled, unsupported } = await deps.pickPath((msg as { mode: "file" | "folder" }).mode);
175
+ if (disposed) return;
176
+ bridge.send({ type: "path_picked", path, canceled, unsupported } satisfies PathPicked);
177
+ });
178
+
157
179
  return {
158
180
  bridge,
159
181
  dispose: async () => {
182
+ disposed = true;
160
183
  chatHandler.dispose();
161
184
  deps.setSharedBridgeServer(null);
162
185
  await bridge.close();
@@ -85,6 +85,21 @@ SAFETY — NEVER DO THESE:
85
85
 
86
86
  `;
87
87
 
88
+ /**
89
+ * Shared tail for every Office (document) profile: the pane is a FULL local xcsh
90
+ * agent — same native tools as the CLI — plus the one safety rule that shelling
91
+ * out makes necessary (don't let the agent kill its own bridge). Interpolated into
92
+ * each document profile so the three stay in sync (DRY).
93
+ */
94
+ const OFFICE_NATIVE_TOOLS_NOTE = `
95
+ NATIVE TOOLS: Beyond the document host tools, you have xcsh's full local toolset — \`bash\` (run shell commands, including CLIs like \`az\`, \`gh\`, \`terraform\`, \`git\` when installed and authenticated), file tools (\`read\`/\`write\`/\`edit\`), and \`grep\` — plus any skills available in this workspace. Reach for them when the task genuinely needs them (pull live data with a CLI, read a local file the user points you at). Prefer the document host tools for document work. Your file tools and shell are confined to the folder xcsh was launched from.
96
+
97
+ SKILLS: When a message begins with \`/<skill-name>\` naming one of your available skills, treat it as a request to USE that skill — read its instructions (open \`skill://<skill-name>\`, or its SKILL.md via \`read\`) and follow them, applying any text after the name as the skill's input.
98
+
99
+ SAFETY:
100
+ - NEVER kill, stop, inspect, or manage the xcsh \`office serve\` process, its bridge ports, or any xcsh process — that bridge IS you; ending it ends the session.
101
+ - NEVER run \`lsof\`/\`fuser\`/\`kill\`/\`pkill\` against the bridge ports or use the shell to manage xcsh itself.`;
102
+
88
103
  /**
89
104
  * Excel task-pane self-awareness prompt. The assistant works the OPEN workbook
90
105
  * via host tools (arriving at runtime over the bridge), thinking in cells,
@@ -95,7 +110,7 @@ You are still xcsh, the F5 Distributed Cloud technical coworker defined in your
95
110
 
96
111
  CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from the data you read; only WRITE to the workbook when the user asks you to.
97
112
 
98
- CONTEXT: You can only access the open workbook. Think in cells, ranges, and — above all — FORMULAS and their dependencies:
113
+ CONTEXT: Your workspace centers on the open workbook. Think in cells, ranges, and — above all — FORMULAS and their dependencies:
99
114
  - Preserve formula relationships. When you change a cell, let dependent cells recompute; do not overwrite a formula with its current value unless asked.
100
115
  - Warn the user before overwriting existing cell contents.
101
116
  - Cite specific cells and ranges precisely (e.g. A1, Sheet1!B2:B10) so the user can follow along.
@@ -105,6 +120,7 @@ TOOLS: Discover the workbook before you answer, then reach for the tool that mat
105
120
  - Use \`read_table\` for structured Excel Tables (it tracks the real extent), \`get_formulas\` to see the formulas behind cells, \`get_cell_metadata\` for cell types/number formats, and \`read_named_range\` to read a defined name.
106
121
  - Use \`sort_filter_table\` to sort or filter a Table by column.
107
122
  - Use \`read_range\`/\`write_range\` for arbitrary cell ranges (bare or sheet-qualified like Sheet2!A1:B10), and \`list_sheets\` when you only need the tab names.
123
+ ${OFFICE_NATIVE_TOOLS_NOTE}
108
124
 
109
125
  BEHAVIOR:
110
126
  - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
@@ -123,7 +139,7 @@ You are still xcsh, the F5 Distributed Cloud technical coworker defined in your
123
139
 
124
140
  CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from what you read; only edit the deck when the user asks you to.
125
141
 
126
- CONTEXT: You can only access the open presentation. Think in slides, shapes, and the slide master:
142
+ CONTEXT: Your workspace centers on the open presentation. Think in slides, shapes, and the slide master:
127
143
  - Conform any new content to the deck's existing template, fonts, and colors — do not introduce a different look.
128
144
  - Make pinpoint, per-slide edits. Do NOT regenerate the whole deck to change one thing.
129
145
  - Refer to slides by number so the user can follow along.
@@ -132,6 +148,7 @@ TOOLS: Discover the deck before you answer, then reach for the tool that matches
132
148
  - Call \`get_presentation_info\` FIRST to discover all slides, their layouts, and shape counts before answering — do not guess the structure.
133
149
  - Use \`read_slide_shapes\` to see all shapes on a slide with their text + position, \`read_slide_layout\` for the layout/master applied to a slide, and \`modify_shape_text\` to edit the text of a named shape.
134
150
  - Use \`read_slides\` for a quick text-only scan of the whole deck, and \`add_text_box\`/\`add_slide\` to create new content.
151
+ ${OFFICE_NATIVE_TOOLS_NOTE}
135
152
 
136
153
  BEHAVIOR:
137
154
  - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
@@ -151,7 +168,7 @@ You are still xcsh, the F5 Distributed Cloud technical coworker defined in your
151
168
 
152
169
  CRITICAL: ALWAYS respond with TEXT FIRST — the user sees a chat pane and expects a conversational reply, not silence while tools run. Answer questions from what you read; only edit the document when the user asks you to.
153
170
 
154
- CONTEXT: You can only access the open document. Think in paragraphs, the current selection, comments, and tracked changes:
171
+ CONTEXT: Your workspace centers on the open document. Think in paragraphs, the current selection, comments, and tracked changes:
155
172
  - Preserve the document's styles and numbering — do not flatten formatting.
156
173
  - Describe your edits so the user can review them, and prefer changes the user can accept or reject.
157
174
  - When the user refers to "the selection" (or "this"), act on the current selection.
@@ -161,6 +178,7 @@ TOOLS: Discover the document before you answer, then reach for the tool that mat
161
178
  - Use \`read_paragraphs\` for styled paragraph content, \`read_selection\` for the current selection, \`get_comments\` for comments, and \`get_tracked_changes\` for revisions.
162
179
  - Use \`read_document\` when you need the full plain text.
163
180
  - Use \`insert_paragraph\` to add content at a specific location (start, end, or before/after the selection), and \`insert_text\` for inline text within a paragraph.
181
+ ${OFFICE_NATIVE_TOOLS_NOTE}
164
182
 
165
183
  BEHAVIOR:
166
184
  - Respond concisely with markdown. The task pane is narrow — avoid long code blocks.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Native OS file/folder picker for the local Office bridge.
3
+ *
4
+ * An Office task-pane WebView cannot open a local file dialog, but the bridge runs
5
+ * in-process on the user's machine, so it can. macOS uses `osascript`'s `choose
6
+ * file`/`choose folder`; other platforms have no one-liner equivalent, so the pane
7
+ * falls back to manual path entry there.
8
+ *
9
+ * ASYNC on purpose: the dialog blocks until the user responds, so a synchronous
10
+ * spawn would stall the bridge's event loop (heartbeats, in-flight turns) for the
11
+ * whole time it's open. `Bun.spawn` + `await proc.exited` keeps the bridge live.
12
+ */
13
+
14
+ export interface PickResult {
15
+ ok: boolean;
16
+ /** Absolute path chosen (no trailing slash), when `ok`. */
17
+ path?: string;
18
+ /** The user dismissed the dialog. */
19
+ canceled?: boolean;
20
+ /** This platform has no native picker; the pane should prompt for a path instead. */
21
+ unsupported?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Open a native picker and resolve the chosen absolute path. Never throws — every
26
+ * failure maps to a `PickResult` flag so the caller can respond gracefully.
27
+ */
28
+ export async function pickPathNative(mode: "file" | "folder"): Promise<PickResult> {
29
+ // macOS only for now — osascript is not present on win32/linux.
30
+ if (process.platform !== "darwin") return { ok: false, unsupported: true };
31
+ const script = mode === "folder" ? "POSIX path of (choose folder)" : "POSIX path of (choose file)";
32
+ try {
33
+ const proc = Bun.spawn(["osascript", "-e", script], { stdout: "pipe", stderr: "ignore" });
34
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
35
+ // Cancel raises AppleScript error -128 → non-zero exit, empty stdout.
36
+ if (code !== 0) return { ok: false, canceled: true };
37
+ let path = out.trim();
38
+ if (!path) return { ok: false, canceled: true };
39
+ // `choose folder` yields a trailing slash; normalize to a bare dir path so it
40
+ // matches the sandbox allow-rule root exactly.
41
+ if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
42
+ return { ok: true, path };
43
+ } catch {
44
+ // osascript missing/blocked — treat as unsupported so the pane offers manual entry.
45
+ return { ok: false, unsupported: true };
46
+ }
47
+ }
@@ -336,8 +336,17 @@ export async function loadExtensionFromFactory(
336
336
 
337
337
  /**
338
338
  * Load extensions from paths.
339
+ *
340
+ * `bundledExtensionNames` opts specific bundled extensions (e.g. `["sandbox-guard"]`)
341
+ * into a session that has otherwise disabled discovery — the headless bridges use
342
+ * this to keep the CLI's filesystem safety net without paying for full discovery.
339
343
  */
340
- export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise<LoadExtensionsResult> {
344
+ export async function loadExtensions(
345
+ paths: string[],
346
+ cwd: string,
347
+ eventBus?: EventBus,
348
+ bundledExtensionNames: readonly string[] = [],
349
+ ): Promise<LoadExtensionsResult> {
341
350
  const extensions: Extension[] = [];
342
351
  const errors: Array<{ path: string; error: string }> = [];
343
352
  const resolvedEventBus = eventBus ?? new EventBus();
@@ -356,11 +365,14 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev
356
365
  }
357
366
  }
358
367
 
359
- return {
360
- extensions,
361
- errors,
362
- runtime,
363
- };
368
+ const result: LoadExtensionsResult = { extensions, errors, runtime };
369
+
370
+ if (bundledExtensionNames.length > 0) {
371
+ const allow = new Set(bundledExtensionNames);
372
+ await loadBundledExtensions(result, cwd, resolvedEventBus, name => !allow.has(name));
373
+ }
374
+
375
+ return result;
364
376
  }
365
377
 
366
378
  interface ExtensionManifest {
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.87.1",
21
- "commit": "961f2b2d80f178d7165977576b0249907c21c68d",
22
- "shortCommit": "961f2b2",
20
+ "version": "19.89.0",
21
+ "commit": "e3c72edcac3935dc928e0599909ce1fc9bdc4832",
22
+ "shortCommit": "e3c72ed",
23
23
  "branch": "main",
24
- "tag": "v19.87.1",
25
- "commitDate": "2026-07-24T18:28:42Z",
26
- "buildDate": "2026-07-24T18:54:10.232Z",
24
+ "tag": "v19.89.0",
25
+ "commitDate": "2026-07-24T23:47:33Z",
26
+ "buildDate": "2026-07-25T00:13:14.668Z",
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/961f2b2d80f178d7165977576b0249907c21c68d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.87.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/e3c72edcac3935dc928e0599909ce1fc9bdc4832",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.89.0"
33
33
  };
@@ -167,7 +167,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
167
167
  name: "Uncategorized",
168
168
  slug: "uncategorized",
169
169
  description: "Resources pending categorization",
170
- resource_count: 6,
170
+ resource_count: 7,
171
171
  resources: [
172
172
  "application_profiles",
173
173
  "authorization_server",
@@ -175,6 +175,7 @@ export const TERRAFORM_INDEX: TerraformIndex = {
175
175
  "mitigated_domain",
176
176
  "protected_application",
177
177
  "protected_domain",
178
+ "registration_approval",
178
179
  ],
179
180
  },
180
181
  {
@@ -1427,6 +1428,17 @@ export const TERRAFORM_INDEX: TerraformIndex = {
1427
1428
  },
1428
1429
  import_syntax: "terraform import xcsh_registration.example namespace/name",
1429
1430
  },
1431
+ registration_approval: {
1432
+ category: "uncategorized",
1433
+ description: "Request for admission approval. configuration",
1434
+ required: ["name", "namespace"],
1435
+ minimal_config:
1436
+ 'resource "xcsh_registration_approval" "example" {\n name = "example-registration-approval"\n namespace = "staging"\n}',
1437
+ dependencies: {
1438
+ requires: [],
1439
+ },
1440
+ import_syntax: "terraform import xcsh_registration_approval.example namespace/name",
1441
+ },
1430
1442
  route: {
1431
1443
  category: "load-balancing",
1432
1444
  description:
package/src/sdk.ts CHANGED
@@ -188,6 +188,12 @@ export interface CreateAgentSessionOptions {
188
188
  additionalExtensionPaths?: string[];
189
189
  /** Disable extension discovery (explicit paths still load). */
190
190
  disableExtensionDiscovery?: boolean;
191
+ /**
192
+ * Bundled extensions to load even when discovery is disabled (e.g.
193
+ * `["sandbox-guard"]`). Lets a headless session keep the CLI's filesystem
194
+ * safety net without paying for full discovery.
195
+ */
196
+ bundledExtensions?: string[];
191
197
  /**
192
198
  * Pre-loaded extensions (skips file discovery).
193
199
  * @internal Used by CLI when extensions are loaded early to parse custom flags.
@@ -1238,7 +1244,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1238
1244
  let extensionsResult: LoadExtensionsResult;
1239
1245
  if (options.disableExtensionDiscovery) {
1240
1246
  const configuredPaths = options.additionalExtensionPaths ?? [];
1241
- extensionsResult = await logger.time("loadExtensions", loadExtensions, configuredPaths, cwd, eventBus);
1247
+ extensionsResult = await logger.time(
1248
+ "loadExtensions",
1249
+ loadExtensions,
1250
+ configuredPaths,
1251
+ cwd,
1252
+ eventBus,
1253
+ options.bundledExtensions ?? [],
1254
+ );
1242
1255
  for (const { path, error } of extensionsResult.errors) {
1243
1256
  logger.error("Failed to load extension", { path, error });
1244
1257
  }
@@ -277,6 +277,12 @@ export interface PromptOptions {
277
277
  attribution?: MessageAttribution;
278
278
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
279
279
  skipCompactionCheck?: boolean;
280
+ /**
281
+ * Raw provider "server tool" specs to inject into THIS turn's request tools, e.g.
282
+ * Anthropic's `{type:"web_search_20250305", name:"web_search", max_uses:N}`. Rides
283
+ * the provider `onPayload` seam (per-turn, no client-side tool registration).
284
+ */
285
+ serverTools?: Record<string, unknown>[];
280
286
  }
281
287
 
282
288
  /** Result from cycleModel() */
@@ -2632,7 +2638,7 @@ export class AgentSession {
2632
2638
  async #promptWithMessage(
2633
2639
  message: AgentMessage,
2634
2640
  expandedText: string,
2635
- options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
2641
+ options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck" | "serverTools"> & {
2636
2642
  prependMessages?: AgentMessage[];
2637
2643
  skipPostPromptRecoveryWait?: boolean;
2638
2644
  },
@@ -2753,7 +2759,10 @@ export class AgentSession {
2753
2759
  return;
2754
2760
  }
2755
2761
 
2756
- const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
2762
+ const agentPromptOptions =
2763
+ options?.toolChoice || options?.serverTools
2764
+ ? { toolChoice: options?.toolChoice, serverTools: options?.serverTools }
2765
+ : undefined;
2757
2766
  await logger.ttftAttr("ttft.agent-loop-total", () =>
2758
2767
  this.#promptAgentWithIdleRetry(messages, agentPromptOptions),
2759
2768
  );
@@ -5601,7 +5610,10 @@ export class AgentSession {
5601
5610
  this.#resolveRetry();
5602
5611
  }
5603
5612
 
5604
- async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
5613
+ async #promptAgentWithIdleRetry(
5614
+ messages: AgentMessage[],
5615
+ options?: { toolChoice?: ToolChoice; serverTools?: Record<string, unknown>[] },
5616
+ ): Promise<void> {
5605
5617
  const deadline = Date.now() + 30_000;
5606
5618
  for (;;) {
5607
5619
  try {