@f5-sales-demo/xcsh 19.64.3 → 19.66.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.64.3",
4
+ "version": "19.66.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",
@@ -31,7 +31,7 @@
31
31
  "xcsh": "src/cli.ts"
32
32
  },
33
33
  "scripts": {
34
- "build": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && bun run generate-console-catalog && bun run generate-console-field-metadata && test -f src/internal-urls/api-spec-index.generated.ts && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset",
34
+ "build": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && bun run generate-console-catalog && bun run generate-console-field-metadata && test -f src/internal-urls/api-spec-index.generated.ts && bun --cwd=../stats scripts/generate-client-bundle.ts --generate && bun --cwd=../office-pane scripts/generate-client-bundle.ts --generate && bun --cwd=../natives run embed:native && bun build --compile --define PI_COMPILED=true --external mupdf --root ../.. ./src/cli.ts --outfile dist/xcsh && bun --cwd=../natives run embed:native --reset && bun --cwd=../stats scripts/generate-client-bundle.ts --reset && bun --cwd=../office-pane scripts/generate-client-bundle.ts --reset",
35
35
  "check": "biome check . && bun run format-prompts -- --check && bun run check:types",
36
36
  "check:types": "bun run generate-build-info && bun run generate-extension-capabilities && bun run generate-api-spec-index && bun run generate-branding-index && bun run generate-terraform-index && tsgo -p tsconfig.json --noEmit",
37
37
  "lint": "biome lint .",
@@ -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.64.3",
60
- "@f5-sales-demo/pi-agent-core": "19.64.3",
61
- "@f5-sales-demo/pi-ai": "19.64.3",
62
- "@f5-sales-demo/pi-natives": "19.64.3",
63
- "@f5-sales-demo/pi-resource-management": "19.64.3",
64
- "@f5-sales-demo/pi-tui": "19.64.3",
65
- "@f5-sales-demo/pi-utils": "19.64.3",
59
+ "@f5-sales-demo/xcsh-stats": "19.66.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.66.0",
61
+ "@f5-sales-demo/pi-ai": "19.66.0",
62
+ "@f5-sales-demo/pi-natives": "19.66.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.66.0",
64
+ "@f5-sales-demo/pi-tui": "19.66.0",
65
+ "@f5-sales-demo/pi-utils": "19.66.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -26,7 +26,7 @@ export interface ExtensionCapabilities {
26
26
 
27
27
  export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
28
28
  "version": "0.1.0",
29
- "contractVersion": "1.8.0",
29
+ "contractVersion": "1.9.0",
30
30
  "multiPortDiscovery": true,
31
31
  "protocol": "tool_request/result",
32
32
  "tools": [
@@ -832,7 +832,10 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
832
832
  "host_tool_call",
833
833
  "host_tool_update",
834
834
  "host_tool_result",
835
- "host_tool_cancel"
835
+ "host_tool_cancel",
836
+ "configure",
837
+ "configure_ack",
838
+ "configure_error"
836
839
  ],
837
840
  "description": "User ↔ xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams chat_delta tokens then a terminal chat_done (with reference links) or chat_error. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn — it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel.",
838
841
  "promptHints": {
@@ -852,6 +855,6 @@ export const EXTENSION_CAPABILITIES: ExtensionCapabilities = {
852
855
  }
853
856
  };
854
857
 
855
- export const EXTENSION_CONTRACT_VERSION = "1.8.0";
858
+ export const EXTENSION_CONTRACT_VERSION = "1.9.0";
856
859
 
857
860
  export const EXTENSION_TOOL_NAMES: readonly string[] = ["ping","capabilities","reload","debug_exec","detach","set_bridge_port","navigate","login","scroll_to","resize_window","tabs_list","tabs_create","tabs_close","click","click_element","click_xy","type_text","form_input","key_press","select_option","label_select","file_upload","read_ax","get_page_text","query_dom","find","wait_for","assert_text","screenshot","read_console","read_network","diag_suspension","diag_bridges","diag_activation","diag_ttft","capture_login_flow","wait_for_api_response","get_page_context","javascript_tool","browser_batch","set_explain_mode","annotate"];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.1.0",
3
- "contractVersion": "1.8.0",
3
+ "contractVersion": "1.9.0",
4
4
  "multiPortDiscovery": true,
5
5
  "protocol": "tool_request/result",
6
6
  "tools": [
@@ -740,7 +740,10 @@
740
740
  "host_tool_call",
741
741
  "host_tool_update",
742
742
  "host_tool_result",
743
- "host_tool_cancel"
743
+ "host_tool_cancel",
744
+ "configure",
745
+ "configure_ack",
746
+ "configure_error"
744
747
  ],
745
748
  "description": "User ↔ xcsh chat over the bridge. The extension side panel sends chat_request (with mode and page-context snapshot); xcsh streams chat_delta tokens then a terminal chat_done (with reference links) or chat_error. Chat ids are prefixed \"c-\". Tool calls during a turn use the normal tool_request flow. chat_stop halts a streaming response. chat_tool_notice is emitted by the EXTENSION (the service worker) to the panel as a best-effort UI signal when a tool runs during a turn — it is NOT sent by xcsh; xcsh must not produce it to avoid double-rendering in the panel.",
746
749
  "promptHints": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.8.0",
2
+ "contractVersion": "1.9.0",
3
3
  "schemas": {
4
4
  "chat_request": {
5
5
  "type": "object",
@@ -356,6 +356,52 @@
356
356
  }
357
357
  }
358
358
  },
359
+ "configure": {
360
+ "type": "object",
361
+ "required": ["type", "token"],
362
+ "properties": {
363
+ "type": {
364
+ "const": "configure",
365
+ "type": "string"
366
+ },
367
+ "baseUrl": {
368
+ "type": "string"
369
+ },
370
+ "token": {
371
+ "type": "string",
372
+ "minLength": 1
373
+ },
374
+ "model": {
375
+ "type": "string"
376
+ }
377
+ }
378
+ },
379
+ "configure_ack": {
380
+ "type": "object",
381
+ "required": ["type", "model"],
382
+ "properties": {
383
+ "type": {
384
+ "const": "configure_ack",
385
+ "type": "string"
386
+ },
387
+ "model": {
388
+ "type": "string"
389
+ }
390
+ }
391
+ },
392
+ "configure_error": {
393
+ "type": "object",
394
+ "required": ["type", "error"],
395
+ "properties": {
396
+ "type": {
397
+ "const": "configure_error",
398
+ "type": "string"
399
+ },
400
+ "error": {
401
+ "type": "string"
402
+ }
403
+ }
404
+ },
359
405
  "host_tool_call": {
360
406
  "type": "object",
361
407
  "required": ["type", "id", "toolCallId", "toolName", "arguments"],
@@ -713,6 +759,24 @@
713
759
  "type": "set_host_tools_error",
714
760
  "error": "Host tool \"office_read_range\" must provide a non-empty description"
715
761
  },
762
+ "configure": {
763
+ "type": "configure",
764
+ "baseUrl": "https://f5ai.pd.f5net.com/anthropic",
765
+ "token": "sk-example-token-123",
766
+ "model": "claude-opus-4-8"
767
+ },
768
+ "configure_no_baseUrl": {
769
+ "type": "configure",
770
+ "token": "sk-example-token-123"
771
+ },
772
+ "configure_ack": {
773
+ "type": "configure_ack",
774
+ "model": "claude-opus-4-8"
775
+ },
776
+ "configure_error": {
777
+ "type": "configure_error",
778
+ "error": "No model anthropic/claude-opus-4-8 available"
779
+ },
716
780
  "host_tool_call": {
717
781
  "type": "host_tool_call",
718
782
  "id": "7295551234567890",
@@ -933,6 +997,23 @@
933
997
  "content": "nope"
934
998
  }
935
999
  }
1000
+ },
1001
+ {
1002
+ "schema": "configure",
1003
+ "why": "token missing",
1004
+ "value": {
1005
+ "type": "configure",
1006
+ "baseUrl": "https://f5ai.pd.f5net.com/anthropic",
1007
+ "model": "claude-opus-4-8"
1008
+ }
1009
+ },
1010
+ {
1011
+ "schema": "configure",
1012
+ "why": "token empty",
1013
+ "value": {
1014
+ "type": "configure",
1015
+ "token": ""
1016
+ }
936
1017
  }
937
1018
  ]
938
1019
  }
@@ -1,4 +1,5 @@
1
1
  import type { AssistantMessage } from "@f5-sales-demo/pi-ai";
2
+ import { DEFAULT_MODEL_ROLE } from "../config/settings-schema";
2
3
  import {
3
4
  isRpcHostToolResult,
4
5
  isRpcHostToolUpdate,
@@ -14,11 +15,15 @@ import {
14
15
  type ChatKeepalive,
15
16
  type ChatReference,
16
17
  type ChatRequest,
18
+ type Configure,
19
+ type ConfigureAck,
20
+ type ConfigureError,
17
21
  type HostToolResult,
18
22
  type HostToolUpdate,
19
23
  type InteractionMode,
20
24
  isChatRequest,
21
25
  isChatStop,
26
+ isConfigure,
22
27
  isSetHostTools,
23
28
  type PageContextSnapshot,
24
29
  type SetHostTools,
@@ -92,6 +97,9 @@ export class ChatHandler {
92
97
  // Host-tool channel (#2046): register client tools, then route the client's
93
98
  // result/update frames back to the correlated pending call in the bridge.
94
99
  else if (isSetHostTools(msg)) this.#handleSetHostTools(msg as unknown as SetHostTools);
100
+ // Provider configuration channel (#2095): swap the LLM provider/model in
101
+ // session memory at runtime (never persisted), then ack or nack.
102
+ else if (isConfigure(msg)) this.#handleConfigure(msg as unknown as Configure);
95
103
  else if (isRpcHostToolResult(msg)) this.#hostToolBridge.handleResult(msg as unknown as HostToolResult);
96
104
  else if (isRpcHostToolUpdate(msg)) this.#hostToolBridge.handleUpdate(msg as unknown as HostToolUpdate);
97
105
  });
@@ -287,6 +295,70 @@ export class ChatHandler {
287
295
  }
288
296
  }
289
297
 
298
+ /** Configure the LLM provider at runtime from a bridge client (#2095), then ack
299
+ * with the selected model so the client can await it before its first prompt.
300
+ * SESSION/RUNTIME MEMORY ONLY — the token is never written to models.yml or the
301
+ * SQLite credential store. Mirrors #handleSetHostTools's try/ack-or-nack shape;
302
+ * never throws out of the handler (a nack keeps a waiting client from hanging).
303
+ *
304
+ * The baked F5 gateway registers its models under the "anthropic" provider
305
+ * (DEFAULT_MODEL_ROLE = "anthropic/claude-opus-4-8"), so that is the provider we
306
+ * (re)configure here. */
307
+ async #handleConfigure(msg: Configure): Promise<void> {
308
+ try {
309
+ const registry = this.#session.modelRegistry;
310
+ const [provider, defaultModelId] = DEFAULT_MODEL_ROLE.split("/");
311
+
312
+ if (msg.baseUrl) {
313
+ // SSRF guard: only an `https:` gateway URL may be dialed. Validate BEFORE
314
+ // registerProvider so a bad URL becomes a configure_error nack (never a
315
+ // silently-ignored frame that hangs the client). We deliberately do NOT
316
+ // block loopback/RFC-1918 targets: an operator-chosen INTERNAL gateway is
317
+ // the whole point (the F5 LiteLLM gateway, and the claude-office CORS proxy
318
+ // at https://127-0-0-1.local-ip.sh:8443 are legitimate targets).
319
+ //
320
+ // Accepted residual (user-requested tradeoff): the operator points xcsh at
321
+ // THEIR OWN gateway with THEIR OWN token over a loopback-only, TLS,
322
+ // Origin-checked bridge (extension-bridge `isAllowedBridgeOrigin`), https is
323
+ // enforced here, and the token is session-only (never persisted to disk).
324
+ const baseUrl = requireHttpsUrl(msg.baseUrl);
325
+
326
+ // baseUrl + apiKey, no models[] → sets the in-memory runtime API key AND
327
+ // overrides the existing provider models' baseUrl/headers (reusing their
328
+ // metadata). Nothing is persisted to disk.
329
+ registry.registerProvider(
330
+ provider,
331
+ {
332
+ baseUrl,
333
+ apiKey: msg.token,
334
+ headers: { "anthropic-beta": "context-1m-2025-08-07" },
335
+ },
336
+ "office-configure",
337
+ );
338
+ } else {
339
+ // Key-only: reuse the baked F5 gateway; set just the non-persistent runtime key.
340
+ registry.authStorage.setRuntimeApiKey(provider, msg.token);
341
+ }
342
+
343
+ const modelId = msg.model ?? this.#session.model?.id ?? defaultModelId;
344
+ const model = registry.find(provider, modelId);
345
+ if (!model) {
346
+ throw new Error(`No model ${provider}/${modelId} available`);
347
+ }
348
+ // setModel validates the API key and throws if missing → becomes configure_error.
349
+ await this.#session.setModel(model);
350
+
351
+ this.#server.send({ type: "configure_ack", model: model.id } satisfies ConfigureAck);
352
+ } catch (err) {
353
+ // Nack instead of throwing (set_host_tools parity): a client awaiting
354
+ // configure_ack would otherwise hang on a bad frame or missing key.
355
+ this.#server.send({
356
+ type: "configure_error",
357
+ error: err instanceof Error ? err.message : String(err),
358
+ } satisfies ConfigureError);
359
+ }
360
+ }
361
+
290
362
  #handleChatStop(stop: { id: string }): void {
291
363
  const chat = this.#activeChats.get(stop.id);
292
364
  if (!chat) return;
@@ -322,6 +394,24 @@ export class ChatHandler {
322
394
  }
323
395
  }
324
396
 
397
+ /** SSRF guard for the `configure` frame's optional gateway `baseUrl`: the URL must
398
+ * parse and use `https:`. Returns the ORIGINAL string unchanged on success (no
399
+ * normalization — the operator's exact gateway path is preserved); throws (→ becomes
400
+ * a `configure_error` nack) for a malformed or non-https URL. Loopback/private hosts
401
+ * are intentionally NOT blocked — the target is an operator-chosen internal gateway. */
402
+ export function requireHttpsUrl(raw: string): string {
403
+ let parsed: URL;
404
+ try {
405
+ parsed = new URL(raw);
406
+ } catch {
407
+ throw new Error(`configure baseUrl is not a valid URL: ${raw}`);
408
+ }
409
+ if (parsed.protocol !== "https:") {
410
+ throw new Error(`configure baseUrl must use https (got "${parsed.protocol}")`);
411
+ }
412
+ return raw;
413
+ }
414
+
325
415
  /** Best-effort classification of an upstream/provider error message into a
326
416
  * ChatErrorReason so the panel can pick a distinct, actionable message. Returns
327
417
  * undefined for an unclassified error (the panel then shows the raw error text).
@@ -3,15 +3,17 @@
3
3
  * Contract source of truth: capabilities.json v1.2.0.
4
4
  */
5
5
 
6
- import {
7
- isRpcHostToolResult,
8
- isRpcHostToolUpdate,
9
- type RpcHostToolCallRequest,
10
- type RpcHostToolCancelRequest,
11
- type RpcHostToolDefinition,
12
- type RpcHostToolResult,
13
- type RpcHostToolUpdate,
14
- } from "../host-tools";
6
+ // Import the guards + wire types from the PURE leaf modules (not the `../host-tools`
7
+ // barrel), so this browser-safe contract can be consumed by a lib.dom (React)
8
+ // TypeScript program without pulling the RpcHostToolBridge's theme/tool-proxy graph.
9
+ import { isRpcHostToolResult, isRpcHostToolUpdate } from "../host-tools/guards";
10
+ import type {
11
+ RpcHostToolCallRequest,
12
+ RpcHostToolCancelRequest,
13
+ RpcHostToolDefinition,
14
+ RpcHostToolResult,
15
+ RpcHostToolUpdate,
16
+ } from "../host-tools/types";
15
17
 
16
18
  // ---------------------------------------------------------------------------
17
19
  // Page context snapshot (auto-attached by extension to every chat_request)
@@ -176,6 +178,43 @@ export interface SetHostToolsError {
176
178
  error: string;
177
179
  }
178
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // Provider configuration channel (contract 1.9.0)
183
+ //
184
+ // Lets a bridge client (the Chrome extension, the office-xcsh add-in) configure
185
+ // xcsh's LLM provider at runtime — after the socket is connected — without
186
+ // restarting the worker and WITHOUT persisting the token to disk. xcsh stays the
187
+ // intelligence engine; this only swaps the provider credentials/model in session
188
+ // memory. Single config in-flight, so — like `set_host_tools` — there is no `id`
189
+ // correlation field. Mirrors the set_host_tools ack/nack shape exactly.
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** Inbound: the client configures the LLM provider. `token` is required and
193
+ * non-empty. `baseUrl` (optional) is an Anthropic-compatible gateway base; when
194
+ * omitted, the baked F5 gateway is reused and only the runtime API key is set.
195
+ * `model` (optional) selects the model id; when omitted, the session default is
196
+ * kept. The token lives in session/runtime memory only — never written to disk. */
197
+ export interface Configure {
198
+ type: "configure";
199
+ baseUrl?: string;
200
+ token: string;
201
+ model?: string;
202
+ }
203
+
204
+ /** Outbound: acks a `configure` with the model id actually selected, so the
205
+ * client can await configuration before its first prompt. */
206
+ export interface ConfigureAck {
207
+ type: "configure_ack";
208
+ model: string;
209
+ }
210
+
211
+ /** Outbound: nacks a `configure` that failed (bad frame, unknown model, missing
212
+ * API key). Emitted instead of the ack so a client awaiting it never hangs. */
213
+ export interface ConfigureError {
214
+ type: "configure_error";
215
+ error: string;
216
+ }
217
+
179
218
  // ---------------------------------------------------------------------------
180
219
  // Validators
181
220
  // ---------------------------------------------------------------------------
@@ -202,6 +241,18 @@ export function isSetHostTools(msg: Record<string, unknown>): boolean {
202
241
  return msg.type === "set_host_tools" && Array.isArray(msg.tools);
203
242
  }
204
243
 
244
+ /** True for a well-formed `configure` frame: a non-empty string `token` is required;
245
+ * `baseUrl`/`model`, when present, must be strings. */
246
+ export function isConfigure(msg: Record<string, unknown>): boolean {
247
+ return (
248
+ msg.type === "configure" &&
249
+ typeof msg.token === "string" &&
250
+ msg.token.length > 0 &&
251
+ (msg.baseUrl === undefined || typeof msg.baseUrl === "string") &&
252
+ (msg.model === undefined || typeof msg.model === "string")
253
+ );
254
+ }
255
+
205
256
  /** Delegates to the neutral guard, which requires `result.content` to be an array. */
206
257
  export function isHostToolResult(msg: Record<string, unknown>): boolean {
207
258
  return isRpcHostToolResult(msg);
@@ -398,6 +398,7 @@ export class BridgeServer {
398
398
  contextBound: info.contextBound,
399
399
  pid: process.pid,
400
400
  wssPort: this.wssPort,
401
+ canConfigureProvider: true,
401
402
  }),
402
403
  );
403
404
  } else {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Static HTTPS listener for the embedded Office task pane.
3
+ *
4
+ * Serves the add-in's page shell + JS bundle + unified manifest + ribbon/app
5
+ * icons over a FIXED `https://127-0-0-1.local-ip.sh:8444` origin — the exact URL
6
+ * the manifest's `code.page` and ribbon icon URLs point at. TLS terminates with
7
+ * the SAME publicly-trusted `*.local-ip.sh` cert the `wss` extension bridge uses
8
+ * (via {@link resolveBridgeTls}), so Office's WebView loads the page with real
9
+ * TLS verification and no local trust / MDM step.
10
+ *
11
+ * This listener is SEPARATE from the extension bridge and serves ONLY static GET
12
+ * requests (no WebSocket). It never auto-starts — it is launched exclusively by
13
+ * `xcsh office serve`, keeping blast radius zero.
14
+ *
15
+ * Compiled vs dev (Option A — the pane is a build-time embedded asset of the
16
+ * binary, not a published library):
17
+ * - COMPILED: the base64 tar.gz baked into `office-pane.generated.txt` is
18
+ * extracted (once, path-traversal-guarded) to `os.tmpdir()/xcsh-office-pane/<hash>`.
19
+ * - DEV: assets are read straight from `packages/office-pane/dist` via a
20
+ * filesystem path — never a module import, so no dependency on the private
21
+ * office-pane package is introduced.
22
+ */
23
+ import * as fs from "node:fs/promises";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { LOCALIP_HOST, resolveBridgeTls } from "./bridge-cert";
27
+ import embeddedPaneArchiveTxt from "./office-pane.generated.txt";
28
+
29
+ /** Fixed listener port — must match the manifest page + ribbon icon URLs. */
30
+ export const OFFICE_PANE_PORT = 8444;
31
+ /** Bind loopback only; the local-ip.sh SAN host resolves here for the WebView. */
32
+ export const OFFICE_PANE_HOSTNAME = "127.0.0.1";
33
+ /** The trusted-origin base URL the task pane is reachable at. */
34
+ export const OFFICE_PANE_URL = `https://${LOCALIP_HOST}:${OFFICE_PANE_PORT}`;
35
+ /** The task-pane page URL (the manifest `code.page`). */
36
+ export const OFFICE_PANE_TASKPANE_URL = `${OFFICE_PANE_URL}/taskpane.html`;
37
+
38
+ const IS_BUN_COMPILED =
39
+ Bun.env.PI_COMPILED ||
40
+ import.meta.url.includes("$bunfs") ||
41
+ import.meta.url.includes("~BUN") ||
42
+ import.meta.url.includes("%7EBUN");
43
+
44
+ // Dev-mode assets: packages/coding-agent/src/browser → packages/office-pane/dist.
45
+ const DEV_DIST_DIR = path.resolve(import.meta.dir, "..", "..", "..", "office-pane", "dist");
46
+ const COMPILED_DIR_ROOT = path.join(os.tmpdir(), "xcsh-office-pane");
47
+
48
+ const getEmbeddedArchive = (() => {
49
+ const txt = embeddedPaneArchiveTxt.replaceAll(/[\s\r\n]/g, "").trim();
50
+ if (!txt) return null;
51
+ return () => Buffer.from(txt, "base64");
52
+ })();
53
+
54
+ let compiledDirPromise: Promise<string> | null = null;
55
+
56
+ /**
57
+ * Sanitize an archive-relative path, rejecting anything that would escape the
58
+ * extraction/serve root (`..`, absolute paths, empty/`.`). Returns the
59
+ * normalized forward-slash path, or `null` when the input is unsafe.
60
+ */
61
+ export function sanitizeArchivePath(archivePath: string): string | null {
62
+ const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
63
+ if (!normalized || normalized === ".") return null;
64
+ if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
65
+ return normalized;
66
+ }
67
+
68
+ async function extractEmbeddedArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
69
+ const archive = new Bun.Archive(archiveBytes);
70
+ const files = await archive.files();
71
+ const extractRoot = path.resolve(outputDir);
72
+
73
+ for (const [archivePath, file] of files) {
74
+ const sanitizedPath = sanitizeArchivePath(archivePath);
75
+ if (!sanitizedPath) continue;
76
+ const destinationPath = path.resolve(extractRoot, sanitizedPath);
77
+ if (!destinationPath.startsWith(extractRoot + path.sep)) {
78
+ throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
79
+ }
80
+ await Bun.write(destinationPath, file);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolve the directory the assets are served from: the extracted embedded
86
+ * bundle in a compiled binary, else `packages/office-pane/dist` in dev.
87
+ */
88
+ export async function getOfficePaneDir(): Promise<string> {
89
+ if (!IS_BUN_COMPILED) return DEV_DIST_DIR;
90
+ if (compiledDirPromise) return compiledDirPromise;
91
+
92
+ const archiveBytes = getEmbeddedArchive?.();
93
+ if (!archiveBytes) {
94
+ throw new Error("Compiled office-pane bundle missing. Rebuild the binary with the embedded office-pane assets.");
95
+ }
96
+
97
+ compiledDirPromise = (async () => {
98
+ const bundleHash = Bun.hash(archiveBytes).toString(16);
99
+ const outputDir = path.join(COMPILED_DIR_ROOT, bundleHash);
100
+ const markerPath = path.join(outputDir, "taskpane.html");
101
+ try {
102
+ if ((await fs.stat(markerPath)).isFile()) return outputDir;
103
+ } catch {}
104
+
105
+ await fs.rm(outputDir, { recursive: true, force: true });
106
+ await fs.mkdir(outputDir, { recursive: true });
107
+ await extractEmbeddedArchive(archiveBytes, outputDir);
108
+ return outputDir;
109
+ })();
110
+
111
+ return compiledDirPromise;
112
+ }
113
+
114
+ /**
115
+ * Pure request handler: map a URL pathname to a file under `dir` and return it
116
+ * with the content-type inferred from its extension, or a 404. `/` maps to
117
+ * `taskpane.html`. Path-traversal is rejected before any filesystem access.
118
+ */
119
+ export async function handleAssetRequest(pathname: string, dir: string): Promise<Response> {
120
+ const requested = pathname === "/" ? "taskpane.html" : pathname.replace(/^\/+/, "");
121
+ const safe = sanitizeArchivePath(requested);
122
+ if (!safe) return new Response("Not Found", { status: 404 });
123
+
124
+ const root = path.resolve(dir);
125
+ const fullPath = path.resolve(root, safe);
126
+ if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
127
+ return new Response("Not Found", { status: 404 });
128
+ }
129
+
130
+ const file = Bun.file(fullPath);
131
+ if (await file.exists()) return new Response(file);
132
+ return new Response("Not Found", { status: 404 });
133
+ }
134
+
135
+ /** Read the embedded/dev manifest.json as text (for `xcsh office manifest`). */
136
+ export async function readManifest(): Promise<string> {
137
+ const dir = await getOfficePaneDir();
138
+ return Bun.file(path.join(dir, "manifest.json")).text();
139
+ }
140
+
141
+ export interface OfficePaneServer {
142
+ port: number;
143
+ url: string;
144
+ taskpaneUrl: string;
145
+ /** true when a publicly-trusted `*.local-ip.sh` cert is in use. */
146
+ trusted: boolean;
147
+ stop: () => void;
148
+ }
149
+
150
+ /**
151
+ * Start the fixed :8444 HTTPS listener serving the embedded/dev assets. Resolves
152
+ * TLS via {@link resolveBridgeTls} (the shared local-ip.sh cert). Serves only GET
153
+ * (405 otherwise); unknown paths return 404.
154
+ */
155
+ export async function startOfficePaneServer(port = OFFICE_PANE_PORT): Promise<OfficePaneServer> {
156
+ const dir = await getOfficePaneDir();
157
+ const tls = await resolveBridgeTls();
158
+
159
+ const server = Bun.serve({
160
+ port,
161
+ hostname: OFFICE_PANE_HOSTNAME,
162
+ tls,
163
+ async fetch(req) {
164
+ if (req.method !== "GET" && req.method !== "HEAD") {
165
+ return new Response("Method Not Allowed", { status: 405 });
166
+ }
167
+ try {
168
+ const url = new URL(req.url);
169
+ return await handleAssetRequest(url.pathname, dir);
170
+ } catch (error) {
171
+ return new Response(error instanceof Error ? error.message : "Internal Server Error", { status: 500 });
172
+ }
173
+ },
174
+ });
175
+
176
+ return {
177
+ port: server.port ?? port,
178
+ url: OFFICE_PANE_URL,
179
+ taskpaneUrl: OFFICE_PANE_TASKPANE_URL,
180
+ trusted: tls !== undefined,
181
+ stop: () => server.stop(),
182
+ };
183
+ }
File without changes
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `xcsh office` command handlers.
3
+ *
4
+ * Drives the embedded Office task pane: start its fixed :8444 HTTPS listener
5
+ * (`serve`), emit the unified manifest (`manifest`), or sideload it into a
6
+ * desktop Office app (`sideload`). The serving/embed logic lives in
7
+ * `../browser/office-pane-server`; this module is the thin, testable CLI seam
8
+ * mirroring `stats-cli.ts` / `chrome-cli.ts`.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import * as fs from "node:fs/promises";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { readManifest, startOfficePaneServer } from "../browser/office-pane-server";
15
+
16
+ /** The subcommands `xcsh office` accepts (also the Args `options` constraint). */
17
+ export const OFFICE_ACTIONS = ["serve", "manifest", "sideload"] as const;
18
+ export type OfficeAction = (typeof OFFICE_ACTIONS)[number];
19
+
20
+ /** Desktop Office apps a sideload can target. */
21
+ export const OFFICE_APPS = ["excel", "powerpoint", "word"] as const;
22
+ export type OfficeApp = (typeof OFFICE_APPS)[number];
23
+
24
+ export interface OfficeCommandArgs {
25
+ action: OfficeAction;
26
+ /** Target app for `sideload` (defaults to excel). */
27
+ app?: OfficeApp;
28
+ /** Optional output path for `manifest`; when omitted, print to stdout. */
29
+ out?: string;
30
+ }
31
+
32
+ /**
33
+ * Resolve the manifest text and optionally write it to `outPath`. Returns the
34
+ * manifest JSON string. Exposed for unit testing.
35
+ */
36
+ export async function writeManifest(outPath?: string): Promise<string> {
37
+ const text = await readManifest();
38
+ if (outPath) {
39
+ await Bun.write(outPath, text);
40
+ }
41
+ return text;
42
+ }
43
+
44
+ /** Start the :8444 listener, print the task-pane URL, and block until killed. */
45
+ async function runServe(): Promise<void> {
46
+ const server = await startOfficePaneServer();
47
+ console.log(`Serving the xcsh Office task pane at ${server.taskpaneUrl}`);
48
+ if (!server.trusted) {
49
+ console.warn(
50
+ "Warning: a publicly-trusted local-ip.sh cert could not be provisioned; using a self-signed fallback. " +
51
+ "Office's WebView may refuse to load the page until the cert is trusted.",
52
+ );
53
+ }
54
+ console.log("Press Ctrl+C to stop.");
55
+ // Bun.serve holds the event loop open; block run() so the process stays alive.
56
+ await new Promise<never>(() => {});
57
+ }
58
+
59
+ /** Emit the manifest to a temp file and run the Office sideload (best-effort). */
60
+ async function runSideload(app: OfficeApp): Promise<void> {
61
+ const text = await readManifest();
62
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "xcsh-office-sideload-"));
63
+ const manifestPath = path.join(dir, "manifest.json");
64
+ await Bun.write(manifestPath, text);
65
+ console.log(`Wrote manifest to ${manifestPath}`);
66
+ console.log(`Sideloading into ${app} (requires the office-addin-debugging / atk tool on PATH)...`);
67
+
68
+ const result = spawnSync("office-addin-debugging", ["start", manifestPath, "desktop", "--app", app], {
69
+ stdio: "inherit",
70
+ });
71
+ if (result.error) {
72
+ const code = (result.error as NodeJS.ErrnoException).code;
73
+ if (code === "ENOENT") {
74
+ console.error(
75
+ "office-addin-debugging was not found on PATH. Install it (e.g. `npm i -g office-addin-debugging` " +
76
+ "or `npm i -g @microsoft/m365agentstoolkit-cli` for `atk`), then run:\n" +
77
+ ` office-addin-debugging start ${manifestPath} desktop --app ${app}`,
78
+ );
79
+ return;
80
+ }
81
+ throw result.error;
82
+ }
83
+ if (typeof result.status === "number" && result.status !== 0) {
84
+ console.error(`office-addin-debugging exited with status ${result.status}.`);
85
+ }
86
+ }
87
+
88
+ /** Dispatch an `xcsh office <action>` invocation. */
89
+ export async function runOfficeCommand(args: OfficeCommandArgs): Promise<void> {
90
+ switch (args.action) {
91
+ case "serve":
92
+ await runServe();
93
+ return;
94
+ case "manifest": {
95
+ const text = await writeManifest(args.out);
96
+ if (args.out) {
97
+ console.log(`Wrote manifest to ${args.out}`);
98
+ } else {
99
+ console.log(text);
100
+ }
101
+ return;
102
+ }
103
+ case "sideload":
104
+ await runSideload(args.app ?? "excel");
105
+ return;
106
+ }
107
+ }
package/src/cli.ts CHANGED
@@ -58,6 +58,7 @@ const commands: CommandEntry[] = [
58
58
  { name: "read", load: () => import("./commands/read").then(m => m.default) },
59
59
  { name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) },
60
60
  { name: "manager", load: () => import("./commands/manager").then(m => m.default) },
61
+ { name: "office", load: () => import("./commands/office").then(m => m.default) },
61
62
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
62
63
  { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
63
64
  { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Serve, print, or sideload the embedded xcsh Office task pane.
3
+ */
4
+ import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
5
+ import { OFFICE_ACTIONS, OFFICE_APPS, type OfficeAction, type OfficeApp, runOfficeCommand } from "../cli/office-cli";
6
+
7
+ export default class Office extends Command {
8
+ static description = "Serve, print, or sideload the embedded xcsh Office task pane";
9
+
10
+ static args = {
11
+ action: Args.string({
12
+ description: "serve | manifest | sideload",
13
+ required: false,
14
+ options: OFFICE_ACTIONS,
15
+ }),
16
+ app: Args.string({
17
+ description: "Office app for sideload (excel | powerpoint | word)",
18
+ required: false,
19
+ options: OFFICE_APPS,
20
+ }),
21
+ };
22
+
23
+ static flags = {
24
+ out: Flags.string({ char: "o", description: "Write manifest.json to this path (manifest action)" }),
25
+ };
26
+
27
+ async run(): Promise<void> {
28
+ const { args, flags } = await this.parse(Office);
29
+ if (!args.action) {
30
+ console.log(`Usage: xcsh office <${OFFICE_ACTIONS.join("|")}>`);
31
+ console.log(" serve Start the https://127-0-0-1.local-ip.sh:8444 task-pane listener");
32
+ console.log(" manifest Print (or -o write) the add-in manifest.json");
33
+ console.log(" sideload Sideload the add-in into a desktop Office app");
34
+ return;
35
+ }
36
+ await runOfficeCommand({
37
+ action: args.action as OfficeAction,
38
+ app: args.app as OfficeApp | undefined,
39
+ out: flags.out,
40
+ });
41
+ }
42
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Transport-neutral host-tool frame guards.
3
+ *
4
+ * Kept in a leaf module — depending ONLY on the wire types (`./types`) and
5
+ * pi-agent-core's `AgentToolResult` *type* — so browser-safe consumers (e.g.
6
+ * `../browser/chat-protocol`) can import the guards WITHOUT loading the
7
+ * `RpcHostToolBridge` in `./host-tools`, which pulls the theme + tool-proxy
8
+ * runtime graph. That graph is fine for the agent runtime but drags node-coupled
9
+ * modules into a browser (lib.dom) TypeScript program.
10
+ */
11
+ import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core/types";
12
+ import type { RpcHostToolResult, RpcHostToolUpdate } from "./types";
13
+
14
+ function isAgentToolResult(value: unknown): value is AgentToolResult<unknown> {
15
+ if (!value || typeof value !== "object") return false;
16
+ const content = (value as { content?: unknown }).content;
17
+ return Array.isArray(content);
18
+ }
19
+
20
+ export function isRpcHostToolResult(value: unknown): value is RpcHostToolResult {
21
+ if (!value || typeof value !== "object") return false;
22
+ const frame = value as { type?: unknown; id?: unknown; result?: unknown };
23
+ return frame.type === "host_tool_result" && typeof frame.id === "string" && isAgentToolResult(frame.result);
24
+ }
25
+
26
+ export function isRpcHostToolUpdate(value: unknown): value is RpcHostToolUpdate {
27
+ if (!value || typeof value !== "object") return false;
28
+ const frame = value as { type?: unknown; id?: unknown; partialResult?: unknown };
29
+ return frame.type === "host_tool_update" && typeof frame.id === "string" && isAgentToolResult(frame.partialResult);
30
+ }
@@ -19,24 +19,6 @@ type PendingHostToolCall = {
19
19
  onUpdate?: AgentToolUpdateCallback<unknown>;
20
20
  };
21
21
 
22
- function isAgentToolResult(value: unknown): value is AgentToolResult<unknown> {
23
- if (!value || typeof value !== "object") return false;
24
- const content = (value as { content?: unknown }).content;
25
- return Array.isArray(content);
26
- }
27
-
28
- export function isRpcHostToolResult(value: unknown): value is RpcHostToolResult {
29
- if (!value || typeof value !== "object") return false;
30
- const frame = value as { type?: unknown; id?: unknown; result?: unknown };
31
- return frame.type === "host_tool_result" && typeof frame.id === "string" && isAgentToolResult(frame.result);
32
- }
33
-
34
- export function isRpcHostToolUpdate(value: unknown): value is RpcHostToolUpdate {
35
- if (!value || typeof value !== "object") return false;
36
- const frame = value as { type?: unknown; id?: unknown; partialResult?: unknown };
37
- return frame.type === "host_tool_update" && typeof frame.id === "string" && isAgentToolResult(frame.partialResult);
38
- }
39
-
40
22
  /**
41
23
  * Validate + normalize incoming host-tool definitions (trim names/labels/descriptions,
42
24
  * enforce non-empty name/description and a JSON-Schema `parameters` object). Shared by
@@ -7,12 +7,8 @@
7
7
  * transport specifics — a driver supplies only an `output: (frame) => void`
8
8
  * sink — so every transport reuses them verbatim.
9
9
  */
10
- export {
11
- isRpcHostToolResult,
12
- isRpcHostToolUpdate,
13
- normalizeHostToolDefinitions,
14
- RpcHostToolBridge,
15
- } from "./host-tools";
10
+ export { isRpcHostToolResult, isRpcHostToolUpdate } from "./guards";
11
+ export { normalizeHostToolDefinitions, RpcHostToolBridge } from "./host-tools";
16
12
  export type {
17
13
  RpcHostToolCallRequest,
18
14
  RpcHostToolCancelRequest,
@@ -6,7 +6,10 @@
6
6
  * back. They are shared verbatim across transports (stdio RPC and the WS chat
7
7
  * bridge), so they live outside of any single transport driver.
8
8
  */
9
- import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core";
9
+ // Narrow subpath (not the barrel) so browser-safe consumers of these wire types
10
+ // don't transitively pull pi-agent-core's runtime graph (agent → pi-utils), which
11
+ // is not lib.dom-safe. AgentToolResult is a pure type.
12
+ import type { AgentToolResult } from "@f5-sales-demo/pi-agent-core/types";
10
13
 
11
14
  export interface RpcHostToolDefinition {
12
15
  name: string;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.64.3",
21
- "commit": "3aaab225af4829e6b49252402e164c07bdb3861c",
22
- "shortCommit": "3aaab22",
20
+ "version": "19.66.0",
21
+ "commit": "a2db5b22aa941b407f9072d154137c57ea932858",
22
+ "shortCommit": "a2db5b2",
23
23
  "branch": "main",
24
- "tag": "v19.64.3",
25
- "commitDate": "2026-07-20T16:07:14Z",
26
- "buildDate": "2026-07-20T16:33:54.078Z",
24
+ "tag": "v19.66.0",
25
+ "commitDate": "2026-07-20T19:28:22Z",
26
+ "buildDate": "2026-07-20T19:57:53.798Z",
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/3aaab225af4829e6b49252402e164c07bdb3861c",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.64.3"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/a2db5b22aa941b407f9072d154137c57ea932858",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.66.0"
33
33
  };