@f5-sales-demo/xcsh 20.3.3 → 20.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +8 -8
- package/src/browser/chat-conformance.json +4 -0
- package/src/browser/chat-handler.ts +129 -22
- package/src/browser/chat-protocol.ts +38 -13
- package/src/browser/headless-bridge.ts +5 -0
- package/src/browser/office-pane-server.ts +12 -2
- package/src/cli/resource-cli.ts +134 -0
- package/src/cli/sandbox-check.ts +84 -21
- package/src/cli/update-cli.ts +1 -1
- package/src/cli.ts +8 -0
- package/src/commands/apply.ts +21 -0
- package/src/commands/create.ts +21 -0
- package/src/commands/delete.ts +32 -0
- package/src/commands/diff.ts +20 -0
- package/src/commands/export.ts +36 -0
- package/src/commands/get.ts +30 -0
- package/src/commands/resource-flags.ts +30 -0
- package/src/commands/self-update.ts +18 -0
- package/src/commands/update.ts +14 -14
- package/src/commands/validate.ts +20 -0
- package/src/config/settings-schema.ts +6 -6
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/plugin-resolve.ts +22 -5
- package/src/locales/ar.json +1 -1
- package/src/locales/de.json +1 -1
- package/src/locales/en.json +1 -1
- package/src/locales/es.json +1 -1
- package/src/locales/fr.json +1 -1
- package/src/locales/hi.json +1 -1
- package/src/locales/it.json +1 -1
- package/src/locales/ja.json +1 -1
- package/src/locales/ko.json +1 -1
- package/src/locales/pt-br.json +1 -1
- package/src/locales/th.json +1 -1
- package/src/locales/zh-cn.json +1 -1
- package/src/locales/zh-tw.json +1 -1
- package/src/main.ts +1 -1
- package/src/modes/controllers/login-model.ts +7 -7
- package/src/prompts/internal-urls/containment.md +16 -17
- package/src/resource-management/index.ts +8 -0
- package/src/sandbox/command-operands.ts +20 -290
- package/src/sandbox/containment.ts +41 -42
- package/src/sandbox/enforce.ts +45 -280
- package/src/sandbox/session-fence.ts +7 -0
- package/src/slash-commands/resource-commands.ts +63 -93
- package/src/tools/bash.ts +9 -3
- package/src/tools/shell-lex.ts +8 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
# Changelog
|
|
4
4
|
|
|
5
|
+
## [20.4.0] - 2026-08-04
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Renamed the executable updater from `xcsh update` to `xcsh self-update`; `xcsh update -f` now performs strict manifest-based resource updates ([#2930](https://github.com/f5-sales-demo/xcsh/issues/2930))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added deterministic, headless `apply`, `create`, `update`, `get`, `delete`, `diff`, `export`, and `validate` commands with file/stdin/directory manifests, reusable exports, stable aggregate JSON reports, and direct environment-based F5 XC authentication without an LLM turn ([#2930](https://github.com/f5-sales-demo/xcsh/issues/2930))
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Prevalidated manifest batches before mutation, preserved strict create and update semantics, rejected unsupported server dry-run, and returned automation-grade exit codes for resource operations ([#2930](https://github.com/f5-sales-demo/xcsh/issues/2930))
|
|
18
|
+
- Prevented sandbox false refusals from path-like Bash and Python source text while limiting account and data containers to discovery protection and preserving xcsh-private runtime isolation ([#2931](https://github.com/f5-sales-demo/xcsh/issues/2931))
|
|
19
|
+
|
|
5
20
|
## [20.3.0] - 2026-08-04
|
|
6
21
|
|
|
7
22
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "20.
|
|
4
|
+
"version": "20.4.1",
|
|
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",
|
|
@@ -60,13 +60,13 @@
|
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
62
62
|
"@mozilla/readability": "^0.6",
|
|
63
|
-
"@f5-sales-demo/xcsh-stats": "20.
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "20.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "20.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "20.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "20.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "20.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "20.
|
|
63
|
+
"@f5-sales-demo/xcsh-stats": "20.4.1",
|
|
64
|
+
"@f5-sales-demo/pi-agent-core": "20.4.1",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "20.4.1",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "20.4.1",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "20.4.1",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "20.4.1",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "20.4.1",
|
|
70
70
|
"@sinclair/typebox": "^0.34",
|
|
71
71
|
"@xterm/headless": "^6.0",
|
|
72
72
|
"ajv": "^8.20",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
normalizeHostToolDefinitions,
|
|
13
13
|
RpcHostToolBridge,
|
|
14
14
|
} from "../host-tools";
|
|
15
|
+
import { LITELLM_LOGIN_MODEL_CHOICES } from "../modes/controllers/login-model";
|
|
15
16
|
import { extractReferences } from "../references";
|
|
16
17
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
17
18
|
import {
|
|
@@ -31,9 +32,11 @@ import {
|
|
|
31
32
|
isChatStop,
|
|
32
33
|
isConfigure,
|
|
33
34
|
isListCommands,
|
|
35
|
+
isListModels,
|
|
34
36
|
isListSkills,
|
|
35
37
|
isSetHostTools,
|
|
36
38
|
isTransportChatRequest,
|
|
39
|
+
type ModelsList,
|
|
37
40
|
type PageContextSnapshot,
|
|
38
41
|
type SetHostTools,
|
|
39
42
|
type SetHostToolsAck,
|
|
@@ -84,6 +87,11 @@ export class ChatHandler {
|
|
|
84
87
|
// Only one pending request at a time (newest wins — a third prompt while one is
|
|
85
88
|
// queued replaces it, so the user's latest intent always runs next).
|
|
86
89
|
#pendingRequest: ChatRequest | null = null;
|
|
90
|
+
// A request can arrive after chat_done but before AgentSession clears its short
|
|
91
|
+
// prompt-in-flight tail. There is then no active chat whose finally block can
|
|
92
|
+
// replay the queue, so that orphaned queue needs its own idle drain.
|
|
93
|
+
#pendingReplayScheduled = false;
|
|
94
|
+
#disposed = false;
|
|
87
95
|
// Transport-neutral host-tool bridge (A1): maps `set_host_tools` definitions to
|
|
88
96
|
// AgentTools whose execute() round-trips a `host_tool_call` back to the WS client
|
|
89
97
|
// and awaits the correlated `host_tool_result`. Reused verbatim from the stdio RPC
|
|
@@ -119,6 +127,9 @@ export class ChatHandler {
|
|
|
119
127
|
// Skills enumeration (#2311): the pane asks for the loaded skills to populate
|
|
120
128
|
// the composer's Skills submenu.
|
|
121
129
|
else if (isListSkills(msg)) this.#handleListSkills();
|
|
130
|
+
// Model enumeration is separate from credential configuration: the pane can
|
|
131
|
+
// select among models xcsh already knows without asking for another token.
|
|
132
|
+
else if (isListModels(msg)) this.#handleListModels();
|
|
122
133
|
// Slash-command enumeration: the pane asks for the session's file-based
|
|
123
134
|
// commands to populate the composer's `/` menu.
|
|
124
135
|
else if (isListCommands(msg)) this.#handleListCommands();
|
|
@@ -127,6 +138,7 @@ export class ChatHandler {
|
|
|
127
138
|
});
|
|
128
139
|
|
|
129
140
|
this.#server.onDisconnected(() => {
|
|
141
|
+
this.#disposed = true;
|
|
130
142
|
this.#pendingRequest = null; // abandon any queued prompt — the bridge is gone
|
|
131
143
|
// Fail any in-flight host-tool call — the client that would answer it is gone.
|
|
132
144
|
this.#hostToolBridge.rejectAllPending("bridge disconnected before host tool completed");
|
|
@@ -158,6 +170,7 @@ export class ChatHandler {
|
|
|
158
170
|
ok: true,
|
|
159
171
|
detail: "xcsh is finishing the current request — yours is queued and will run next.",
|
|
160
172
|
});
|
|
173
|
+
this.#scheduleOrphanedPendingReplay();
|
|
161
174
|
return;
|
|
162
175
|
}
|
|
163
176
|
|
|
@@ -205,14 +218,14 @@ export class ChatHandler {
|
|
|
205
218
|
data: img.data,
|
|
206
219
|
mimeType: img.mimeType,
|
|
207
220
|
}));
|
|
208
|
-
// "Search the web" toggle → add Anthropic's server-side web-search tool for this
|
|
209
|
-
// turn only. The gateway executes it and returns cited results; source URLs the
|
|
210
|
-
// model writes inline flow to the pane's Sources chips via extractReferences.
|
|
211
|
-
const serverTools = req.web_search
|
|
212
|
-
? [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }]
|
|
213
|
-
: undefined;
|
|
214
|
-
|
|
215
221
|
try {
|
|
222
|
+
// Select the native provider-side descriptor from the ACTIVE model on every
|
|
223
|
+
// turn. This must happen after a model switch and before prompt() transmits a
|
|
224
|
+
// payload; an unsupported API is rejected locally instead of receiving another
|
|
225
|
+
// provider's raw tool shape.
|
|
226
|
+
const serverTools = req.web_search
|
|
227
|
+
? officeWebSearchServerTools(this.#session.model?.api ?? "unconfigured")
|
|
228
|
+
: undefined;
|
|
216
229
|
chat.promptAt = Date.now();
|
|
217
230
|
await this.#session.prompt(prompt, { expandPromptTemplates: false, synthetic: false, images, serverTools });
|
|
218
231
|
} catch (err: unknown) {
|
|
@@ -234,6 +247,43 @@ export class ChatHandler {
|
|
|
234
247
|
}
|
|
235
248
|
}
|
|
236
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Drain a queue created during AgentSession's terminal streaming tail. Normal
|
|
252
|
+
* mid-turn queues are replayed by the active chat's finally block; this path is
|
|
253
|
+
* only scheduled when no active chat exists to own that replay.
|
|
254
|
+
*/
|
|
255
|
+
#scheduleOrphanedPendingReplay(): void {
|
|
256
|
+
if (this.#pendingReplayScheduled || this.#disposed || !this.#pendingRequest || this.#activeChats.size > 0) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.#pendingReplayScheduled = true;
|
|
260
|
+
void this.#replayPendingAfterIdle();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #replayPendingAfterIdle(): Promise<void> {
|
|
264
|
+
try {
|
|
265
|
+
await this.#session.waitForIdle();
|
|
266
|
+
// waitForIdle can resolve in AgentSession's prompt finally just before its
|
|
267
|
+
// prompt-in-flight counter is decremented. Yield one task so isStreaming is
|
|
268
|
+
// the settled state rather than that terminal edge.
|
|
269
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
270
|
+
} catch {
|
|
271
|
+
// A disposing session can reject its idle wait. Teardown owns any terminal
|
|
272
|
+
// frame and clears the pending request; never replay into that session.
|
|
273
|
+
this.#pendingReplayScheduled = false;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
this.#pendingReplayScheduled = false;
|
|
277
|
+
if (this.#disposed || !this.#pendingRequest || this.#activeChats.size > 0) return;
|
|
278
|
+
if (this.#session.isStreaming) {
|
|
279
|
+
this.#scheduleOrphanedPendingReplay();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const pending = this.#pendingRequest;
|
|
283
|
+
this.#pendingRequest = null;
|
|
284
|
+
void this.#handleChatRequest(pending);
|
|
285
|
+
}
|
|
286
|
+
|
|
237
287
|
#handleSessionEvent(chat: ActiveChat, event: AgentSessionEvent): void {
|
|
238
288
|
if (chat.terminalSent) return;
|
|
239
289
|
|
|
@@ -377,8 +427,8 @@ export class ChatHandler {
|
|
|
377
427
|
* SQLite credential store. Mirrors #handleSetHostTools's try/ack-or-nack shape;
|
|
378
428
|
* never throws out of the handler (a nack keeps a waiting client from hanging).
|
|
379
429
|
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
430
|
+
* A curated model route identifies the concrete provider, model, effort, and
|
|
431
|
+
* provider-specific gateway path. The pane supplies only a gateway root. */
|
|
382
432
|
async #handleConfigure(msg: Configure): Promise<void> {
|
|
383
433
|
try {
|
|
384
434
|
const registry = this.#session.modelRegistry;
|
|
@@ -386,9 +436,21 @@ export class ChatHandler {
|
|
|
386
436
|
if (!defaultModel) {
|
|
387
437
|
throw new Error("Invalid baked default model selector");
|
|
388
438
|
}
|
|
389
|
-
const
|
|
439
|
+
const currentChoice = LITELLM_LOGIN_MODEL_CHOICES.find(
|
|
440
|
+
choice => choice.provider === this.#session.model?.provider && choice.modelId === this.#session.model.id,
|
|
441
|
+
);
|
|
442
|
+
const modelId = msg.model ?? currentChoice?.modelId ?? defaultModel.id;
|
|
443
|
+
const choice = LITELLM_LOGIN_MODEL_CHOICES.find(candidate => candidate.modelId === modelId);
|
|
444
|
+
const matchingProviders = (["anthropic", "litellm"] as const).filter(candidate =>
|
|
445
|
+
registry.find(candidate, modelId),
|
|
446
|
+
);
|
|
447
|
+
const provider = choice?.provider ?? (matchingProviders.length === 1 ? matchingProviders[0] : undefined);
|
|
448
|
+
if (!provider) {
|
|
449
|
+
throw new Error(`No unambiguous Office model route for ${modelId}`);
|
|
450
|
+
}
|
|
451
|
+
const thinkingLevel = choice?.thinkingLevel ?? defaultModel.thinkingLevel;
|
|
390
452
|
|
|
391
|
-
if (msg.baseUrl) {
|
|
453
|
+
if (msg.baseUrl && msg.token) {
|
|
392
454
|
// SSRF guard: only an `https:` gateway URL may be dialed. Validate BEFORE
|
|
393
455
|
// registerProvider so a bad URL becomes a configure_error nack (never a
|
|
394
456
|
// silently-ignored frame that hangs the client). We deliberately do NOT
|
|
@@ -400,7 +462,8 @@ export class ChatHandler {
|
|
|
400
462
|
// THEIR OWN gateway with THEIR OWN token over a loopback-only, TLS,
|
|
401
463
|
// Origin-checked bridge (extension-bridge `isAllowedBridgeOrigin`), https is
|
|
402
464
|
// enforced here, and the token is session-only (never persisted to disk).
|
|
403
|
-
const
|
|
465
|
+
const gatewayRoot = requireHttpsUrl(msg.baseUrl);
|
|
466
|
+
const baseUrl = providerGatewayBaseUrl(gatewayRoot, provider);
|
|
404
467
|
|
|
405
468
|
// baseUrl + apiKey, no models[] → sets the in-memory runtime API key AND
|
|
406
469
|
// overrides the existing provider models' baseUrl/headers (reusing their
|
|
@@ -414,14 +477,11 @@ export class ChatHandler {
|
|
|
414
477
|
},
|
|
415
478
|
"office-configure",
|
|
416
479
|
);
|
|
417
|
-
} else {
|
|
480
|
+
} else if (msg.token) {
|
|
418
481
|
// Key-only: reuse the baked F5 gateway; set just the non-persistent runtime key.
|
|
419
482
|
registry.authStorage.setRuntimeApiKey(provider, msg.token);
|
|
420
483
|
}
|
|
421
484
|
|
|
422
|
-
const currentDefaultModelId =
|
|
423
|
-
this.#session.model?.provider === provider ? this.#session.model.id : defaultModelId;
|
|
424
|
-
const modelId = msg.model ?? currentDefaultModelId;
|
|
425
485
|
const model = registry.find(provider, modelId);
|
|
426
486
|
if (!model) {
|
|
427
487
|
throw new Error(`No model ${provider}/${modelId} available`);
|
|
@@ -464,6 +524,23 @@ export class ChatHandler {
|
|
|
464
524
|
this.#server.send({ type: "skills", skills: toSkillSummaries(this.#session.skills) } satisfies SkillsList);
|
|
465
525
|
}
|
|
466
526
|
|
|
527
|
+
/** Reply with the curated Office models that resolve in the live registry. */
|
|
528
|
+
#handleListModels(): void {
|
|
529
|
+
const models = LITELLM_LOGIN_MODEL_CHOICES.filter(choice =>
|
|
530
|
+
this.#session.modelRegistry.find(choice.provider, choice.modelId),
|
|
531
|
+
).map(choice => ({ id: choice.modelId, label: choice.label }));
|
|
532
|
+
const current = this.#session.model?.id;
|
|
533
|
+
// A ready Office session normally always has an active model. During an
|
|
534
|
+
// unconfigured startup there is no truthful `current` value to advertise, so
|
|
535
|
+
// leave the selector empty until the next connection instead of inventing one.
|
|
536
|
+
if (!current) return;
|
|
537
|
+
this.#server.send({
|
|
538
|
+
type: "models",
|
|
539
|
+
current,
|
|
540
|
+
models,
|
|
541
|
+
} satisfies ModelsList);
|
|
542
|
+
}
|
|
543
|
+
|
|
467
544
|
/** Reply to `list_commands` with the session's file-based slash commands (name +
|
|
468
545
|
* description) so the pane can populate the composer's `/` menu. Pure read — the
|
|
469
546
|
* commands are already discovered; the template bodies never cross the wire. */
|
|
@@ -487,6 +564,7 @@ export class ChatHandler {
|
|
|
487
564
|
}
|
|
488
565
|
|
|
489
566
|
dispose(): void {
|
|
567
|
+
this.#disposed = true;
|
|
490
568
|
this.#pendingRequest = null; // abandon any queued prompt — don't replay into a dead session
|
|
491
569
|
// Fail any in-flight host-tool call — the session is going away.
|
|
492
570
|
this.#hostToolBridge.rejectAllPending("bridge disconnected before host tool completed");
|
|
@@ -502,11 +580,37 @@ export class ChatHandler {
|
|
|
502
580
|
}
|
|
503
581
|
}
|
|
504
582
|
|
|
505
|
-
/**
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
|
|
509
|
-
|
|
583
|
+
/** Native provider-side web-search descriptors supported by the Office surface.
|
|
584
|
+
* Ordinary xcsh and Office host tools do not use this seam; they remain function
|
|
585
|
+
* tools and are preserved by the agent's payload composer. */
|
|
586
|
+
export function officeWebSearchServerTools(modelApi: string): Record<string, unknown>[] {
|
|
587
|
+
switch (modelApi) {
|
|
588
|
+
case "anthropic-messages":
|
|
589
|
+
return [{ type: "web_search_20250305", name: "web_search", max_uses: 5 }];
|
|
590
|
+
case "openai-completions":
|
|
591
|
+
return [{ type: "web_search_preview" }];
|
|
592
|
+
default:
|
|
593
|
+
throw new Error(`Office web search is unsupported for model API ${modelApi}`);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const OFFICE_GATEWAY_PROVIDER_PATHS = {
|
|
598
|
+
anthropic: "/anthropic",
|
|
599
|
+
litellm: "/api/v1",
|
|
600
|
+
} as const;
|
|
601
|
+
|
|
602
|
+
/** Derive the selected provider's API base from a normalized gateway root. */
|
|
603
|
+
export function providerGatewayBaseUrl(
|
|
604
|
+
gatewayRoot: string,
|
|
605
|
+
provider: keyof typeof OFFICE_GATEWAY_PROVIDER_PATHS,
|
|
606
|
+
): string {
|
|
607
|
+
return `${gatewayRoot}${OFFICE_GATEWAY_PROVIDER_PATHS[provider]}`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** SSRF guard and backward-compatible root normalizer for the `configure` frame's
|
|
611
|
+
* optional gateway URL. Saved full-path URLs are reduced to their HTTPS origin;
|
|
612
|
+
* the selected model then determines the provider path. Loopback/private hosts are
|
|
613
|
+
* intentionally allowed because the target is an operator-chosen internal gateway. */
|
|
510
614
|
export function requireHttpsUrl(raw: string): string {
|
|
511
615
|
let parsed: URL;
|
|
512
616
|
try {
|
|
@@ -517,7 +621,7 @@ export function requireHttpsUrl(raw: string): string {
|
|
|
517
621
|
if (parsed.protocol !== "https:") {
|
|
518
622
|
throw new Error(`configure baseUrl must use https (got "${parsed.protocol}")`);
|
|
519
623
|
}
|
|
520
|
-
return
|
|
624
|
+
return parsed.origin;
|
|
521
625
|
}
|
|
522
626
|
|
|
523
627
|
/** Classify an upstream/provider error into the closed public reason vocabulary.
|
|
@@ -535,6 +639,9 @@ export function classifyChatErrorReason(message: string): ChatErrorReason {
|
|
|
535
639
|
) {
|
|
536
640
|
return "provider-5xx";
|
|
537
641
|
}
|
|
642
|
+
if (/\b(?:401|403)\b|\bunauthorized\b|\bforbidden\b|\bapi key\b|\bauthentication\b|\bcredentials?\b/.test(m)) {
|
|
643
|
+
return "provider-auth";
|
|
644
|
+
}
|
|
538
645
|
if (/\b4\d\d\b|forbidden|unauthorized|invalid model|bad request|not found|too many requests|rate limit/.test(m)) {
|
|
539
646
|
return "provider-4xx";
|
|
540
647
|
}
|
|
@@ -89,8 +89,8 @@ interface ChatRequestBase {
|
|
|
89
89
|
* grants them to the filesystem sandbox for the session and tells the model they
|
|
90
90
|
* are available to read on demand. */
|
|
91
91
|
contextPaths?: string[];
|
|
92
|
-
/** When true, the engine adds
|
|
93
|
-
* turn
|
|
92
|
+
/** When true, the engine adds the active model API's native server-side
|
|
93
|
+
* web-search tool to this turn (the "Search the web" composer toggle). */
|
|
94
94
|
web_search?: boolean;
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -128,6 +128,24 @@ export interface ListSkills {
|
|
|
128
128
|
type: "list_skills";
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/** Client → engine: enumerate the curated models available to the Office pane. */
|
|
132
|
+
export interface ListModels {
|
|
133
|
+
type: "list_models";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One model option surfaced in the Office composer's model selector. */
|
|
137
|
+
export interface ModelInfo {
|
|
138
|
+
id: string;
|
|
139
|
+
label: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Engine → client: available Office models and the active model id. */
|
|
143
|
+
export interface ModelsList {
|
|
144
|
+
type: "models";
|
|
145
|
+
current: string;
|
|
146
|
+
models: ModelInfo[];
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
/** One skill surfaced to the pane's Skills submenu (name + human description). */
|
|
132
150
|
export interface SkillInfo {
|
|
133
151
|
name: string;
|
|
@@ -196,6 +214,7 @@ export const CHAT_ERROR_REASONS = [
|
|
|
196
214
|
"session-disposed", // the worker session was torn down
|
|
197
215
|
"token-expired", // F5 XC API token expired
|
|
198
216
|
"token-expiring", // F5 XC API token is about to expire
|
|
217
|
+
"provider-auth", // upstream provider rejected its credential
|
|
199
218
|
"provider-4xx", // upstream provider rejected the request (client error)
|
|
200
219
|
"provider-5xx", // upstream provider failed (server error) — retryable
|
|
201
220
|
] as const;
|
|
@@ -278,15 +297,14 @@ export interface SetHostToolsError {
|
|
|
278
297
|
// field. Mirrors the set_host_tools ack/nack shape exactly.
|
|
279
298
|
// ---------------------------------------------------------------------------
|
|
280
299
|
|
|
281
|
-
/** Inbound: the client configures
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* kept. The token lives in session/runtime memory only — never written to disk. */
|
|
300
|
+
/** Inbound: the client configures credentials, selects a model, or both.
|
|
301
|
+
* `baseUrl` is a gateway root and requires a non-empty `token`; xcsh derives its
|
|
302
|
+
* provider path from `model`. A model-only frame reuses xcsh's existing provider
|
|
303
|
+
* credentials. Runtime credentials are never written to disk. */
|
|
286
304
|
export interface Configure {
|
|
287
305
|
type: "configure";
|
|
288
306
|
baseUrl?: string;
|
|
289
|
-
token
|
|
307
|
+
token?: string;
|
|
290
308
|
model?: string;
|
|
291
309
|
}
|
|
292
310
|
|
|
@@ -372,15 +390,22 @@ export function isSetHostTools(msg: Record<string, unknown>): boolean {
|
|
|
372
390
|
return msg.type === "set_host_tools" && Array.isArray(msg.tools);
|
|
373
391
|
}
|
|
374
392
|
|
|
375
|
-
|
|
376
|
-
|
|
393
|
+
export function isListModels(msg: Record<string, unknown>): boolean {
|
|
394
|
+
return msg.type === "list_models";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** True for a well-formed `configure` frame. At least a non-empty token or model is
|
|
398
|
+
* required, and a gateway root may never be sent without its token. */
|
|
377
399
|
export function isConfigure(msg: Record<string, unknown>): boolean {
|
|
400
|
+
const hasToken = typeof msg.token === "string" && msg.token.trim().length > 0;
|
|
401
|
+
const hasModel = typeof msg.model === "string" && msg.model.trim().length > 0;
|
|
378
402
|
return (
|
|
379
403
|
msg.type === "configure" &&
|
|
380
|
-
|
|
381
|
-
msg.token.
|
|
404
|
+
(hasToken || hasModel) &&
|
|
405
|
+
(msg.token === undefined || typeof msg.token === "string") &&
|
|
382
406
|
(msg.baseUrl === undefined || typeof msg.baseUrl === "string") &&
|
|
383
|
-
(msg.model === undefined || typeof msg.model === "string")
|
|
407
|
+
(msg.model === undefined || typeof msg.model === "string") &&
|
|
408
|
+
(msg.baseUrl === undefined || hasToken)
|
|
384
409
|
);
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -18,6 +18,7 @@ import { getProjectDir, getXCSHConfigDir } from "@f5-sales-demo/pi-utils";
|
|
|
18
18
|
import { createAgentSession } from "../sdk";
|
|
19
19
|
import { ContextService } from "../services/xcsh-context";
|
|
20
20
|
import { deriveTenantEnv } from "../services/xcsh-env";
|
|
21
|
+
import { SessionManager } from "../session/session-manager";
|
|
21
22
|
import { resolveBridgeTls } from "./bridge-cert";
|
|
22
23
|
import { ChatHandler } from "./chat-handler";
|
|
23
24
|
import { isPickPath, type PathPicked } from "./chat-protocol";
|
|
@@ -147,6 +148,10 @@ export async function startHeadlessChatBridge(deps: HeadlessBridgeDeps = default
|
|
|
147
148
|
const { session } = await deps.createAgentSession({
|
|
148
149
|
cwd,
|
|
149
150
|
hasUI: false,
|
|
151
|
+
// Office conversations can contain private workbook and working-directory
|
|
152
|
+
// data. Keep the entire headless session ephemeral instead of inheriting
|
|
153
|
+
// createAgentSession's file-backed default.
|
|
154
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
150
155
|
toolNames: [...OFFICE_TOOL_NAMES],
|
|
151
156
|
customTools: [],
|
|
152
157
|
// Headless: no MCP/LSP/extension discovery — lean, no network/blocking prompts.
|
|
@@ -299,7 +299,10 @@ export async function getOfficePaneDir(): Promise<string> {
|
|
|
299
299
|
/**
|
|
300
300
|
* Pure request handler: map a URL pathname to a file under `dir` and return it
|
|
301
301
|
* with the content-type inferred from its extension, or a 404. `/` maps to
|
|
302
|
-
* `taskpane.html`.
|
|
302
|
+
* `taskpane.html`. Successful assets are `no-store`: every compiled build uses
|
|
303
|
+
* the same stable URLs, and Excel's WebView otherwise reuses an older pane bundle
|
|
304
|
+
* after a new xcsh binary is sideloaded. Path-traversal is rejected before any
|
|
305
|
+
* filesystem access.
|
|
303
306
|
*/
|
|
304
307
|
export async function handleAssetRequest(pathname: string, dir: string): Promise<Response> {
|
|
305
308
|
const requested = pathname === "/" ? "taskpane.html" : pathname.replace(/^\/+/, "");
|
|
@@ -313,7 +316,14 @@ export async function handleAssetRequest(pathname: string, dir: string): Promise
|
|
|
313
316
|
}
|
|
314
317
|
|
|
315
318
|
const file = Bun.file(fullPath);
|
|
316
|
-
if (await file.exists())
|
|
319
|
+
if (await file.exists()) {
|
|
320
|
+
return new Response(file, {
|
|
321
|
+
headers: {
|
|
322
|
+
"Cache-Control": "no-store",
|
|
323
|
+
"Content-Type": file.type,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}
|
|
317
327
|
return new Response("Not Found", { status: 404 });
|
|
318
328
|
}
|
|
319
329
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
formatManifestOutput,
|
|
4
|
+
formatResourceOperationReport,
|
|
5
|
+
ResourceClient,
|
|
6
|
+
type ResourceOperation,
|
|
7
|
+
type ResourceOperationReport,
|
|
8
|
+
readManifestInputs,
|
|
9
|
+
runResourceOperation,
|
|
10
|
+
} from "@f5-sales-demo/pi-resource-management";
|
|
11
|
+
import { kindResolver } from "../resource-management/index";
|
|
12
|
+
|
|
13
|
+
export interface ResourceCliArgs {
|
|
14
|
+
operation: ResourceOperation;
|
|
15
|
+
filenames?: string[];
|
|
16
|
+
kind?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
all?: boolean;
|
|
19
|
+
namespace?: string;
|
|
20
|
+
outputFormat?: "json" | "yaml" | "table" | "wide";
|
|
21
|
+
recursive?: boolean;
|
|
22
|
+
dryRun?: "client";
|
|
23
|
+
resultFile?: string;
|
|
24
|
+
outputFile?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResourceEnvironment {
|
|
28
|
+
apiUrl?: string;
|
|
29
|
+
apiToken?: string;
|
|
30
|
+
defaultNamespace?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveResourceEnvironment(
|
|
34
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
35
|
+
): ResourceEnvironment {
|
|
36
|
+
return {
|
|
37
|
+
apiUrl: env.XCSH_API_URL,
|
|
38
|
+
apiToken: env.XCSH_API_TOKEN,
|
|
39
|
+
defaultNamespace: env.XCSH_NAMESPACE,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function exitCodeForResourceReport(report: ResourceOperationReport): 0 | 1 | 2 {
|
|
44
|
+
if (report.success) return 0;
|
|
45
|
+
const failures = report.results.filter(result => result.status === "error" || result.status === "skipped");
|
|
46
|
+
return failures.length > 0 && failures.every(result => result.error?.kind === "validation") ? 2 : 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatResourceCliOutput(
|
|
50
|
+
report: ResourceOperationReport,
|
|
51
|
+
format: "json" | "yaml" | "table" | "wide",
|
|
52
|
+
): string {
|
|
53
|
+
if (report.operation === "export" && (format === "json" || format === "yaml")) {
|
|
54
|
+
const manifests = report.results.flatMap(result => (result.manifest ? [result.manifest] : []));
|
|
55
|
+
return formatManifestOutput(manifests, format);
|
|
56
|
+
}
|
|
57
|
+
return formatResourceOperationReport(report, format);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runResourceCli(args: ResourceCliArgs): Promise<void> {
|
|
61
|
+
const env = resolveResourceEnvironment();
|
|
62
|
+
let report: ResourceOperationReport;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const manifestOperation = ["apply", "create", "update", "get", "delete", "diff", "validate"].includes(
|
|
66
|
+
args.operation,
|
|
67
|
+
);
|
|
68
|
+
let inputs =
|
|
69
|
+
manifestOperation && (args.filenames?.length ?? 0) > 0
|
|
70
|
+
? await readManifestInputs(args.filenames ?? [], args.recursive ?? false)
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
if (args.operation === "delete" && !inputs && args.kind && args.name) {
|
|
74
|
+
const namespace = args.namespace ?? env.defaultNamespace;
|
|
75
|
+
const rawObject = { kind: args.kind, metadata: { name: args.name, namespace }, spec: {} };
|
|
76
|
+
inputs = [
|
|
77
|
+
{
|
|
78
|
+
index: 0,
|
|
79
|
+
sourcePath: "command-line",
|
|
80
|
+
manifest: {
|
|
81
|
+
kind: args.kind,
|
|
82
|
+
metadata: { name: args.name, namespace },
|
|
83
|
+
spec: {},
|
|
84
|
+
rawObject,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const requiresApi = args.operation !== "validate";
|
|
91
|
+
const client =
|
|
92
|
+
requiresApi && env.apiUrl && env.apiToken
|
|
93
|
+
? new ResourceClient({
|
|
94
|
+
apiUrl: env.apiUrl,
|
|
95
|
+
apiToken: env.apiToken,
|
|
96
|
+
namespace: env.defaultNamespace ?? "",
|
|
97
|
+
})
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
report = await runResourceOperation({
|
|
101
|
+
operation: args.operation,
|
|
102
|
+
kindResolver,
|
|
103
|
+
client,
|
|
104
|
+
inputs,
|
|
105
|
+
kind: args.kind,
|
|
106
|
+
name: args.name,
|
|
107
|
+
all: args.all,
|
|
108
|
+
namespaceOverride: args.namespace,
|
|
109
|
+
defaultNamespace: env.defaultNamespace,
|
|
110
|
+
dryRun: args.dryRun,
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
report = {
|
|
114
|
+
schemaVersion: 1,
|
|
115
|
+
operation: args.operation,
|
|
116
|
+
success: false,
|
|
117
|
+
counts: { total: 1, succeeded: 0, failed: 1, error: 1 },
|
|
118
|
+
results: [
|
|
119
|
+
{
|
|
120
|
+
index: 0,
|
|
121
|
+
status: "error",
|
|
122
|
+
error: { kind: "validation", message: error instanceof Error ? error.message : String(error) },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const json = `${JSON.stringify(report, null, 2)}\n`;
|
|
129
|
+
if (args.resultFile) await fs.writeFile(args.resultFile, json, "utf8");
|
|
130
|
+
const output = `${formatResourceCliOutput(report, args.outputFormat ?? "table")}\n`;
|
|
131
|
+
if (args.outputFile) await fs.writeFile(args.outputFile, output, "utf8");
|
|
132
|
+
else process.stdout.write(output);
|
|
133
|
+
process.exitCode = exitCodeForResourceReport(report);
|
|
134
|
+
}
|