@f5-sales-demo/xcsh 21.0.0 → 21.1.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 +8 -8
- package/src/cli/args.ts +98 -9
- package/src/cli/flag-spec.ts +11 -4
- package/src/cli/plugin-cli.ts +11 -0
- package/src/cli/sandbox-check.ts +35 -6
- package/src/commands/launch.ts +1 -3
- package/src/config/model-registry.ts +2 -0
- package/src/discovery/helpers.ts +18 -4
- package/src/exec/bash-executor.ts +1 -0
- package/src/internal-urls/build-info-runtime.ts +1 -1
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/internal-urls/console-resolve.ts +6 -5
- package/src/main.ts +84 -42
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/model-selector.ts +5 -1
- package/src/prompts/internal-urls/containment.md +1 -3
- package/src/prompts/tools/bash.md +3 -2
- package/src/sandbox/command-operands.ts +82 -5
- package/src/sandbox/containment.ts +20 -8
- package/src/sandbox/enforce.ts +12 -5
- package/src/sandbox/session-fence.ts +1 -0
- package/src/sdk.ts +30 -32
- package/src/session/agent-session.ts +35 -13
- package/src/system-prompt.ts +24 -20
- package/src/thinking.ts +5 -2
- package/src/tools/bash.ts +26 -19
- package/src/tools/catalog-workflow-runner.ts +6 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/xcsh",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.1.0",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
64
|
-
"@f5-sales-demo/pi-agent-core": "21.
|
|
65
|
-
"@f5-sales-demo/pi-ai": "21.
|
|
66
|
-
"@f5-sales-demo/pi-natives": "21.
|
|
67
|
-
"@f5-sales-demo/pi-resource-management": "21.
|
|
68
|
-
"@f5-sales-demo/pi-tui": "21.
|
|
69
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
70
|
-
"@f5-sales-demo/xcsh-stats": "21.
|
|
64
|
+
"@f5-sales-demo/pi-agent-core": "21.1.0",
|
|
65
|
+
"@f5-sales-demo/pi-ai": "21.1.0",
|
|
66
|
+
"@f5-sales-demo/pi-natives": "21.1.0",
|
|
67
|
+
"@f5-sales-demo/pi-resource-management": "21.1.0",
|
|
68
|
+
"@f5-sales-demo/pi-tui": "21.1.0",
|
|
69
|
+
"@f5-sales-demo/pi-utils": "21.1.0",
|
|
70
|
+
"@f5-sales-demo/xcsh-stats": "21.1.0",
|
|
71
71
|
"@mozilla/readability": "^0.6",
|
|
72
72
|
"@sinclair/typebox": "^0.34",
|
|
73
73
|
"@xterm/headless": "^6.0",
|
package/src/cli/args.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
|
|
18
18
|
export type Mode = "text" | "json" | "rpc" | "acp";
|
|
19
19
|
|
|
20
|
+
export type ExtensionFlagRegistry = ReadonlyMap<string, { type: "boolean" | "string" }>;
|
|
21
|
+
|
|
20
22
|
export interface Args {
|
|
21
23
|
cwd?: string;
|
|
22
24
|
allowHome?: boolean;
|
|
@@ -224,7 +226,97 @@ function isValueToken(token: string | undefined): token is string {
|
|
|
224
226
|
return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
|
|
225
227
|
}
|
|
226
228
|
|
|
227
|
-
export
|
|
229
|
+
export interface LaunchBootstrapArgs {
|
|
230
|
+
allowHome?: boolean;
|
|
231
|
+
cwd?: string;
|
|
232
|
+
noSandbox?: boolean;
|
|
233
|
+
allowPath: string[];
|
|
234
|
+
noMemories?: boolean;
|
|
235
|
+
hooks: string[];
|
|
236
|
+
extensions: string[];
|
|
237
|
+
noExtensions?: boolean;
|
|
238
|
+
pluginDirs: string[];
|
|
239
|
+
preExtensionExit: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const BOOTSTRAP_VALUE_FLAGS = new Set(["allow-path", "hook", "extension", "plugin-dir"]);
|
|
243
|
+
const PRE_EXTENSION_EXITS = new Set(["version", "list-models", "export"]);
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Read only the built-in controls needed to load extensions.
|
|
247
|
+
*
|
|
248
|
+
* This is deliberately not an argument parse: it never classifies positional input, files, or
|
|
249
|
+
* extension flags. It only follows the known built-in grammar far enough to avoid mistaking a
|
|
250
|
+
* built-in value for a discovery control. The authoritative parse happens after this scan.
|
|
251
|
+
*/
|
|
252
|
+
export function scanLaunchBootstrapArgs(args: readonly string[]): LaunchBootstrapArgs {
|
|
253
|
+
const result: LaunchBootstrapArgs = {
|
|
254
|
+
allowPath: [],
|
|
255
|
+
hooks: [],
|
|
256
|
+
extensions: [],
|
|
257
|
+
pluginDirs: [],
|
|
258
|
+
preExtensionExit: false,
|
|
259
|
+
};
|
|
260
|
+
const tokens = normalizeFlagTokens(args);
|
|
261
|
+
|
|
262
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
263
|
+
const token = tokens[i];
|
|
264
|
+
if (token === "--") break;
|
|
265
|
+
if (!token.startsWith("-") || token === "-") continue;
|
|
266
|
+
|
|
267
|
+
const name = token.startsWith("--") ? token.slice(2) : flagNameForChar(token.slice(1));
|
|
268
|
+
if (name === undefined) continue;
|
|
269
|
+
const spec = flagSpec(name);
|
|
270
|
+
if (!spec) continue;
|
|
271
|
+
|
|
272
|
+
let value: string | true = true;
|
|
273
|
+
if (spec.arity === "optional-value") {
|
|
274
|
+
if (isValueToken(tokens[i + 1])) value = tokens[++i];
|
|
275
|
+
} else if (takesValue(spec)) {
|
|
276
|
+
if (tokens[i + 1] === undefined) continue;
|
|
277
|
+
value = tokens[++i];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (PRE_EXTENSION_EXITS.has(name)) result.preExtensionExit = true;
|
|
281
|
+
if (name === "allow-home") result.allowHome = true;
|
|
282
|
+
if (name === "no-sandbox") result.noSandbox = true;
|
|
283
|
+
if (name === "no-memories") result.noMemories = true;
|
|
284
|
+
if (name === "no-extensions") result.noExtensions = true;
|
|
285
|
+
if (BOOTSTRAP_VALUE_FLAGS.has(name) && value !== true) {
|
|
286
|
+
if (name === "allow-path") result.allowPath.push(value);
|
|
287
|
+
if (name === "hook") result.hooks.push(value);
|
|
288
|
+
if (name === "extension") result.extensions.push(value);
|
|
289
|
+
if (name === "plugin-dir") result.pluginDirs.push(value);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface ResolvedLaunchArgs {
|
|
297
|
+
bootstrap: LaunchBootstrapArgs;
|
|
298
|
+
parsed: Args;
|
|
299
|
+
extensionFlags?: ExtensionFlagRegistry;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve extension registrations before the one parse whose result drives launch behavior.
|
|
304
|
+
* Version/model-list/export retain their pre-extension fast path.
|
|
305
|
+
*/
|
|
306
|
+
export async function resolveLaunchArgs(
|
|
307
|
+
args: readonly string[],
|
|
308
|
+
loadExtensionFlags: (bootstrap: LaunchBootstrapArgs) => Promise<ExtensionFlagRegistry>,
|
|
309
|
+
): Promise<ResolvedLaunchArgs> {
|
|
310
|
+
const bootstrap = scanLaunchBootstrapArgs(args);
|
|
311
|
+
const extensionFlags = bootstrap.preExtensionExit ? undefined : await loadExtensionFlags(bootstrap);
|
|
312
|
+
return {
|
|
313
|
+
bootstrap,
|
|
314
|
+
parsed: parseArgs([...args], extensionFlags),
|
|
315
|
+
...(extensionFlags ? { extensionFlags } : {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseArgs(args: string[], extensionFlags?: ExtensionFlagRegistry): Args {
|
|
228
320
|
const result: Args = {
|
|
229
321
|
messages: [],
|
|
230
322
|
fileArgs: [],
|
|
@@ -282,23 +374,20 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
|
|
282
374
|
continue;
|
|
283
375
|
}
|
|
284
376
|
|
|
285
|
-
// Extension
|
|
377
|
+
// Extension registrations are loaded before this authoritative parse.
|
|
286
378
|
const extFlag = name === undefined ? undefined : extensionFlags?.get(name);
|
|
287
379
|
if (extFlag && name !== undefined) {
|
|
288
380
|
if (extFlag.type === "boolean") {
|
|
289
|
-
|
|
381
|
+
const inlineValue = token.startsWith("--") ? token.split("=", 2)[1] : undefined;
|
|
382
|
+
result.unknownFlags.set(name, inlineValue === undefined ? true : inlineValue === "true");
|
|
290
383
|
} else if (i + 1 < tokens.length) {
|
|
291
384
|
result.unknownFlags.set(name, tokens[++i]);
|
|
292
385
|
}
|
|
293
386
|
continue;
|
|
294
387
|
}
|
|
295
388
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// the next token silently discards the user's prompt whenever the flag turns out to be
|
|
299
|
-
// boolean (`xcsh -p --verbose "do work"`). Leaving it means a string extension flag's value
|
|
300
|
-
// still reaches `messages`, which is the pre-existing behaviour and the lesser harm — the
|
|
301
|
-
// real fix is to load extensions before the first parse, which is out of scope here.
|
|
389
|
+
// Do not consume a following token for a genuine unknown flag: it may be prompt content.
|
|
390
|
+
// Registered extension flags never reach this path because launch discovers them first.
|
|
302
391
|
result.unrecognizedFlags.push({ token, name: name ?? token.replace(/^-+/, "") });
|
|
303
392
|
}
|
|
304
393
|
|
package/src/cli/flag-spec.ts
CHANGED
|
@@ -175,20 +175,23 @@ export function validateInlineFlagSyntax(args: readonly string[], extensionFlags
|
|
|
175
175
|
if (!inline) continue;
|
|
176
176
|
|
|
177
177
|
const spec = flagSpec(inline.name);
|
|
178
|
-
|
|
179
|
-
if (isBoolean) {
|
|
178
|
+
if (spec?.arity === "boolean") {
|
|
180
179
|
throw new CliUsageError(`--${inline.name} is a boolean flag and does not take a value`);
|
|
181
180
|
}
|
|
181
|
+
if (extensionFlags?.get(inline.name)?.type === "boolean" && inline.value !== "true" && inline.value !== "false") {
|
|
182
|
+
throw new CliUsageError(`--${inline.name} is a boolean flag and expects true or false`);
|
|
183
|
+
}
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
/**
|
|
186
188
|
* Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
|
|
187
189
|
*
|
|
188
|
-
* A boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
|
|
190
|
+
* A built-in boolean flag with `=` is an error rather than a guess: accepting `--no-sandbox=true` invites
|
|
189
191
|
* `--no-sandbox=false`, which the parser has no way to express, and quietly reading it as "on" would
|
|
190
192
|
* be exactly the class of bug #2469 reports. Unknown names are left intact so the unknown-flag path
|
|
191
193
|
* can report the token as the user wrote it.
|
|
194
|
+
* Extension boolean flags accept explicit true or false values through their runtime map.
|
|
192
195
|
*
|
|
193
196
|
* Short forms are untouched: no shell convention makes `-p=x` mean `-p x`.
|
|
194
197
|
*/
|
|
@@ -223,7 +226,11 @@ export function normalizeFlagTokens(args: readonly string[], extensionFlags?: Ex
|
|
|
223
226
|
|
|
224
227
|
const extension = extensionFlags?.get(name);
|
|
225
228
|
if (extension) {
|
|
226
|
-
|
|
229
|
+
if (extension.type === "boolean") {
|
|
230
|
+
normalized.push(arg);
|
|
231
|
+
} else {
|
|
232
|
+
normalized.push(`--${name}`, value);
|
|
233
|
+
}
|
|
227
234
|
continue;
|
|
228
235
|
}
|
|
229
236
|
|
package/src/cli/plugin-cli.ts
CHANGED
|
@@ -425,6 +425,17 @@ async function handleInstall(
|
|
|
425
425
|
}
|
|
426
426
|
|
|
427
427
|
if (target.type === "marketplace") {
|
|
428
|
+
if (flags.dryRun) {
|
|
429
|
+
const preview = {
|
|
430
|
+
action: "install",
|
|
431
|
+
target: `${target.name}@${target.marketplace}`,
|
|
432
|
+
scope: flags.scope ?? "user",
|
|
433
|
+
dryRun: true,
|
|
434
|
+
};
|
|
435
|
+
if (flags.json) console.log(JSON.stringify(preview, null, 2));
|
|
436
|
+
else console.log(chalk.dim(`[dry-run] Would install ${preview.target} (${preview.scope})`));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
428
439
|
try {
|
|
429
440
|
const entry = await mktMgr.installPlugin(target.name, target.marketplace, {
|
|
430
441
|
force: flags.force,
|
package/src/cli/sandbox-check.ts
CHANGED
|
@@ -6,10 +6,17 @@ import { executeShell, fencePermits } from "@f5-sales-demo/pi-natives";
|
|
|
6
6
|
import { isEnoent } from "@f5-sales-demo/pi-utils";
|
|
7
7
|
import { Settings } from "../config/settings";
|
|
8
8
|
import { fenceForNative } from "../exec/bash-executor";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildContainmentFence,
|
|
11
|
+
type ContainmentFence,
|
|
12
|
+
containmentStatus,
|
|
13
|
+
fenceVerdict,
|
|
14
|
+
seatbeltFenceVerdict,
|
|
15
|
+
} from "../sandbox/containment";
|
|
10
16
|
import { evaluateToolCall } from "../sandbox/enforce";
|
|
11
17
|
import {
|
|
12
18
|
SANDBOX_CHECK_NAMED_SIBLING_ENV,
|
|
19
|
+
SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV,
|
|
13
20
|
SANDBOX_OPERATOR_HOME_ENV,
|
|
14
21
|
SANDBOX_SESSION_ROOT_ENV,
|
|
15
22
|
sandboxCheckSiblingRoot,
|
|
@@ -74,6 +81,13 @@ function sanitizeDetail(value: string, redactions: readonly Redaction[]): string
|
|
|
74
81
|
return sanitized.length > 500 ? `${sanitized.slice(0, 497)}...` : sanitized;
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
function inheritedNamedSiblingDenied(value: string | undefined): boolean | undefined {
|
|
85
|
+
if (value === undefined) return undefined;
|
|
86
|
+
if (value === "denied") return true;
|
|
87
|
+
if (value === "allowed") return false;
|
|
88
|
+
throw new Error(`invalid ${SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV}: expected "allowed" or "denied"`);
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
function errnoFromOutput(output: string): string {
|
|
78
92
|
if (/operation not permitted/iu.test(output)) return "EPERM";
|
|
79
93
|
if (/permission denied/iu.test(output)) return "EACCES";
|
|
@@ -210,6 +224,10 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
210
224
|
const inheritedHome = process.env[SANDBOX_OPERATOR_HOME_ENV];
|
|
211
225
|
const inheritedSibling = process.env[SANDBOX_CHECK_NAMED_SIBLING_ENV];
|
|
212
226
|
const inheritedProfile = inheritedWorkspace !== undefined;
|
|
227
|
+
const inheritedSiblingDenied =
|
|
228
|
+
inheritedProfile && inheritedSibling !== undefined
|
|
229
|
+
? inheritedNamedSiblingDenied(process.env[SANDBOX_CHECK_NAMED_SIBLING_EXPECTATION_ENV])
|
|
230
|
+
: undefined;
|
|
213
231
|
const workspaceInput = inheritedWorkspace ?? process.cwd();
|
|
214
232
|
const homeInput = inheritedHome ?? os.homedir();
|
|
215
233
|
redactions.push([workspaceInput, "<workspace>"], [homeInput, "<operator-home>"]);
|
|
@@ -462,7 +480,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
462
480
|
);
|
|
463
481
|
});
|
|
464
482
|
|
|
465
|
-
await check("named sibling
|
|
483
|
+
await check("named sibling follows the active boundary", async () => {
|
|
466
484
|
const displayPath = "<session-parent>/<synthetic-sibling>";
|
|
467
485
|
let liveSibling = inheritedSibling;
|
|
468
486
|
if (liveSibling === undefined) {
|
|
@@ -480,13 +498,24 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
480
498
|
return exceptionOutcome("create named sibling fixture", displayPath, error, redactions);
|
|
481
499
|
}
|
|
482
500
|
}
|
|
501
|
+
const seatbeltDeniesSibling =
|
|
502
|
+
inheritedSiblingDenied ??
|
|
503
|
+
(backend.backend === "seatbelt" && seatbeltFenceVerdict(liveFence, liveSibling, "read") === "deny");
|
|
483
504
|
const result = await shellProbe(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
undefined,
|
|
505
|
+
`cd ${quote(liveSibling)} && test "$(cat named.txt)" = sibling`,
|
|
506
|
+
liveWorkspace,
|
|
507
|
+
inheritedProfile ? undefined : liveFence,
|
|
487
508
|
abortController.signal,
|
|
488
509
|
);
|
|
489
|
-
return shellOutcome(
|
|
510
|
+
return shellOutcome(
|
|
511
|
+
result,
|
|
512
|
+
!seatbeltDeniesSibling,
|
|
513
|
+
seatbeltDeniesSibling
|
|
514
|
+
? "Seatbelt must deny a named sibling outside the workspace"
|
|
515
|
+
: "live profile must allow a named sibling read",
|
|
516
|
+
displayPath,
|
|
517
|
+
redactions,
|
|
518
|
+
);
|
|
490
519
|
});
|
|
491
520
|
|
|
492
521
|
if (backend.osEnforced) {
|
package/src/commands/launch.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import { APP_NAME } from "@f5-sales-demo/pi-utils";
|
|
6
6
|
import { Args, Command } from "@f5-sales-demo/pi-utils/cli";
|
|
7
|
-
import { parseArgs } from "../cli/args";
|
|
8
7
|
import { buildCliFlags } from "../cli/flag-spec";
|
|
9
8
|
import { runRootCommand } from "../main";
|
|
10
9
|
|
|
@@ -36,7 +35,6 @@ export default class Index extends Command {
|
|
|
36
35
|
static strict = false;
|
|
37
36
|
|
|
38
37
|
async run(): Promise<void> {
|
|
39
|
-
|
|
40
|
-
await runRootCommand(parsed, this.argv);
|
|
38
|
+
await runRootCommand(this.argv);
|
|
41
39
|
}
|
|
42
40
|
}
|
|
@@ -187,6 +187,8 @@ const ThinkingControlModeSchema = Type.Union([
|
|
|
187
187
|
const ModelThinkingSchema = Type.Object({
|
|
188
188
|
minLevel: EffortSchema,
|
|
189
189
|
maxLevel: EffortSchema,
|
|
190
|
+
defaultLevel: Type.Optional(EffortSchema),
|
|
191
|
+
canDisable: Type.Optional(Type.Boolean()),
|
|
190
192
|
mode: ThinkingControlModeSchema,
|
|
191
193
|
});
|
|
192
194
|
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -843,12 +843,13 @@ export async function listXcshPluginRoots(
|
|
|
843
843
|
roots.push(...projectRoots, ...deduped);
|
|
844
844
|
}
|
|
845
845
|
|
|
846
|
-
// Merge --plugin-dir roots (highest precedence) on every fresh load
|
|
846
|
+
// Merge --plugin-dir roots (highest precedence) on every fresh load. Local roots use
|
|
847
|
+
// an artificial marketplace ID, so identity must be matched by manifest plugin name;
|
|
848
|
+
// comparing full IDs would leave the installed copy active beside the candidate.
|
|
847
849
|
if (injectedPluginDirRoots.length > 0) {
|
|
848
|
-
const
|
|
849
|
-
const filtered = roots.filter(r => !injectedIds.has(r.id));
|
|
850
|
+
const merged = prioritizeInjectedPluginRoots(roots, injectedPluginDirRoots);
|
|
850
851
|
roots.length = 0;
|
|
851
|
-
roots.push(...
|
|
852
|
+
roots.push(...merged);
|
|
852
853
|
}
|
|
853
854
|
|
|
854
855
|
const result = { roots, warnings };
|
|
@@ -856,6 +857,19 @@ export async function listXcshPluginRoots(
|
|
|
856
857
|
return result;
|
|
857
858
|
}
|
|
858
859
|
|
|
860
|
+
export function prioritizeInjectedPluginRoots(
|
|
861
|
+
installed: XcshPluginRoot[],
|
|
862
|
+
injected: XcshPluginRoot[],
|
|
863
|
+
): XcshPluginRoot[] {
|
|
864
|
+
const seen = new Set<string>();
|
|
865
|
+
const winners = injected.filter(root => {
|
|
866
|
+
if (seen.has(root.plugin)) return false;
|
|
867
|
+
seen.add(root.plugin);
|
|
868
|
+
return true;
|
|
869
|
+
});
|
|
870
|
+
return [...winners, ...installed.filter(root => !seen.has(root.plugin))];
|
|
871
|
+
}
|
|
872
|
+
|
|
859
873
|
export interface XcshPluginSummary {
|
|
860
874
|
/** Registry id (root.plugin) — the key the `xcsh://plugin/<id>` resolver matches on. */
|
|
861
875
|
id: string;
|
|
@@ -24,6 +24,7 @@ export function fenceForNative(fence: ContainmentFence | undefined) {
|
|
|
24
24
|
allowReadOnly: [...fence.allowReadOnly],
|
|
25
25
|
allowWriteOnly: [...fence.allowWriteOnly],
|
|
26
26
|
deny: [...fence.deny],
|
|
27
|
+
denyOnSeatbelt: [...fence.denyOnSeatbelt],
|
|
27
28
|
denyEnumerate: [...fence.denyEnumerate],
|
|
28
29
|
};
|
|
29
30
|
}
|
|
@@ -171,7 +171,7 @@ function renderContainment(containment: ContainmentStatus | null): string {
|
|
|
171
171
|
if (!containment) return "";
|
|
172
172
|
// `landlock` is derived rather than another field on the status, because the template needs a
|
|
173
173
|
// boolean and Handlebars cannot compare strings. It gates the Linux-only costs — unlistable split
|
|
174
|
-
// directories
|
|
174
|
+
// directories and no setuid — which are true of that backend and no other.
|
|
175
175
|
return prompt.render(containmentTemplate, {
|
|
176
176
|
containment: { ...containment, landlock: containment.backend === "landlock" },
|
|
177
177
|
});
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "21.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "21.1.0",
|
|
21
|
+
"commit": "5c6e84a195bcf81ba561bf85c5ecf7a47d8c62c1",
|
|
22
|
+
"shortCommit": "5c6e84a",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v21.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v21.1.0",
|
|
25
|
+
"commitDate": "2026-08-28T19:03:09+00:00",
|
|
26
|
+
"buildDate": "2026-08-28T19:42:43.325Z",
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/5c6e84a195bcf81ba561bf85c5ecf7a47d8c62c1",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v21.1.0"
|
|
33
33
|
};
|
|
@@ -121,10 +121,10 @@ function renderResource(resource: string, catalog: ConsoleCatalogData, fieldMeta
|
|
|
121
121
|
const doc = (parseYaml(raw) ?? {}) as Record<string, unknown>;
|
|
122
122
|
const console_ = (doc.console ?? {}) as Record<string, unknown>;
|
|
123
123
|
const lines = [`# ${(doc.label as string | undefined) ?? key}`, ""];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
const routePattern = typeof console_.route_pattern === "string" ? console_.route_pattern : undefined;
|
|
125
|
+
if (routePattern) {
|
|
126
|
+
const routePrefix = typeof console_.route_prefix === "string" ? console_.route_prefix : undefined;
|
|
127
|
+
const fullRoute = routePrefix ? `${routePrefix}${routePattern}` : routePattern;
|
|
128
128
|
lines.push(`**Route:** \`${fullRoute}\``, "");
|
|
129
129
|
}
|
|
130
130
|
if (Array.isArray(console_.menu_path)) lines.push(`**Menu:** ${(console_.menu_path as string[]).join(" › ")}`, "");
|
|
@@ -155,8 +155,9 @@ function renderWorkflow(resource: string, operation: string, catalog: ConsoleCat
|
|
|
155
155
|
for (const s of steps) {
|
|
156
156
|
const sel = s.selector ? ` \`${s.selector}\`` : "";
|
|
157
157
|
const val = s.value != null ? ` = ${JSON.stringify(s.value)}` : "";
|
|
158
|
+
const action = typeof s.action === "string" ? s.action : "unknown";
|
|
158
159
|
lines.push(
|
|
159
|
-
`1. **${
|
|
160
|
+
`1. **${action}**${sel}${val} — ${(s.description as string | undefined) ?? (s.id as string | undefined) ?? ""}`,
|
|
160
161
|
);
|
|
161
162
|
}
|
|
162
163
|
return `${lines.join("\n")}\n`;
|
package/src/main.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { ChatHandler } from "./browser/chat-handler";
|
|
|
28
28
|
import { type BridgeServer, startBridgeServer } from "./browser/extension-bridge";
|
|
29
29
|
import { setSharedBridgeServer } from "./browser/provider";
|
|
30
30
|
import { invalidate as invalidateFsCache } from "./capability/fs";
|
|
31
|
-
import { type Args,
|
|
31
|
+
import { type Args, resolveLaunchArgs } from "./cli/args";
|
|
32
32
|
import { processFileArguments } from "./cli/file-processor";
|
|
33
33
|
import { LAUNCH_FLAGS } from "./cli/flag-spec";
|
|
34
34
|
import { buildInitialMessage } from "./cli/initial-message";
|
|
@@ -46,7 +46,8 @@ import {
|
|
|
46
46
|
resolveActiveProjectRegistryPath,
|
|
47
47
|
} from "./discovery/helpers";
|
|
48
48
|
import { exportFromFile } from "./export/html";
|
|
49
|
-
import
|
|
49
|
+
import { discoverAndLoadExtensions, loadExtensions } from "./extensibility/extensions";
|
|
50
|
+
import type { ExtensionFlag, ExtensionUIContext, LoadExtensionsResult } from "./extensibility/extensions/types";
|
|
50
51
|
import {
|
|
51
52
|
getInstalledPluginsRegistryPath,
|
|
52
53
|
getMarketplacesCacheDir,
|
|
@@ -66,7 +67,7 @@ import { resolveResumableSession, type SessionInfo, SessionManager } from "./ses
|
|
|
66
67
|
import { profileDump, profileMark } from "./startup-profile";
|
|
67
68
|
import { resolvePromptInput } from "./system-prompt";
|
|
68
69
|
import type { LspStartupServerInfo } from "./tools";
|
|
69
|
-
import
|
|
70
|
+
import { EventBus } from "./utils/event-bus";
|
|
70
71
|
import { fuzzyFilter } from "./utils/fuzzy";
|
|
71
72
|
|
|
72
73
|
async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
|
|
@@ -314,7 +315,7 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
|
|
|
314
315
|
return undefined;
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
async function maybeAutoChdir(parsed: Args): Promise<void> {
|
|
318
|
+
async function maybeAutoChdir(parsed: Pick<Args, "allowHome" | "cwd">): Promise<void> {
|
|
318
319
|
if (parsed.allowHome || parsed.cwd) {
|
|
319
320
|
return;
|
|
320
321
|
}
|
|
@@ -597,7 +598,23 @@ function reportUnrecognizedFlags(
|
|
|
597
598
|
process.exit(1);
|
|
598
599
|
}
|
|
599
600
|
|
|
600
|
-
|
|
601
|
+
function collectExtensionFlags(result: LoadExtensionsResult): {
|
|
602
|
+
flags: Map<string, ExtensionFlag>;
|
|
603
|
+
registeredNames: Map<string, string>;
|
|
604
|
+
} {
|
|
605
|
+
const flags = new Map<string, ExtensionFlag>();
|
|
606
|
+
const registeredNames = new Map<string, string>();
|
|
607
|
+
for (const extension of result.extensions) {
|
|
608
|
+
for (const [registeredName, flag] of extension.flags) {
|
|
609
|
+
const cliName = registeredName.replace(/^--/, "");
|
|
610
|
+
flags.set(cliName, flag);
|
|
611
|
+
registeredNames.set(cliName, registeredName);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return { flags, registeredNames };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export async function runRootCommand(rawArgs: string[]): Promise<void> {
|
|
601
618
|
logger.startTiming();
|
|
602
619
|
profileMark("entry: runtime + module-graph loaded (pre-main)");
|
|
603
620
|
|
|
@@ -605,8 +622,60 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
605
622
|
// Will be re-initialized with user preferences later
|
|
606
623
|
await logger.time("initTheme:initial", initTheme);
|
|
607
624
|
|
|
608
|
-
|
|
609
|
-
|
|
625
|
+
let preloadedExtensions: LoadExtensionsResult | undefined;
|
|
626
|
+
let extensionEventBus: EventBus | undefined;
|
|
627
|
+
let registeredFlagNames = new Map<string, string>();
|
|
628
|
+
const resolved = await resolveLaunchArgs(rawArgs, async bootstrap => {
|
|
629
|
+
await logger.time("maybeAutoChdir", maybeAutoChdir, bootstrap);
|
|
630
|
+
const cwd = getProjectDir();
|
|
631
|
+
const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
|
|
632
|
+
if (bootstrap.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
|
|
633
|
+
if (bootstrap.noMemories) sandboxOverrides["memories.enabled"] = false;
|
|
634
|
+
if (bootstrap.allowPath.length > 0) {
|
|
635
|
+
sandboxOverrides["sandbox.allowRead"] = bootstrap.allowPath;
|
|
636
|
+
sandboxOverrides["sandbox.allowWrite"] = bootstrap.allowPath;
|
|
637
|
+
}
|
|
638
|
+
await logger.time("settings:init", Settings.init, {
|
|
639
|
+
cwd,
|
|
640
|
+
overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
|
|
641
|
+
});
|
|
642
|
+
initializeWithSettings(settings);
|
|
643
|
+
|
|
644
|
+
const home = os.homedir();
|
|
645
|
+
if (bootstrap.pluginDirs.length > 0) {
|
|
646
|
+
await logger.time("injectPluginDirRoots", injectPluginDirRoots, home, bootstrap.pluginDirs, cwd);
|
|
647
|
+
} else {
|
|
648
|
+
await logger.time("preloadPluginRoots", preloadPluginRoots, home, cwd);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const configuredPaths = [...bootstrap.extensions, ...bootstrap.hooks];
|
|
652
|
+
extensionEventBus = new EventBus();
|
|
653
|
+
preloadedExtensions = bootstrap.noExtensions
|
|
654
|
+
? await logger.time("loadExtensions", loadExtensions, configuredPaths, cwd, extensionEventBus)
|
|
655
|
+
: await logger.time(
|
|
656
|
+
"discoverAndLoadExtensions",
|
|
657
|
+
discoverAndLoadExtensions,
|
|
658
|
+
[...configuredPaths, ...(settings.get("extensions") ?? [])],
|
|
659
|
+
cwd,
|
|
660
|
+
extensionEventBus,
|
|
661
|
+
settings.get("disabledExtensions") ?? [],
|
|
662
|
+
);
|
|
663
|
+
for (const { path: extensionPath, error } of preloadedExtensions.errors) {
|
|
664
|
+
logger.error("Failed to load extension", { path: extensionPath, error });
|
|
665
|
+
}
|
|
666
|
+
const collected = collectExtensionFlags(preloadedExtensions);
|
|
667
|
+
registeredFlagNames = collected.registeredNames;
|
|
668
|
+
return collected.flags;
|
|
669
|
+
});
|
|
670
|
+
const parsedArgs = resolved.parsed;
|
|
671
|
+
if (resolved.bootstrap.preExtensionExit) {
|
|
672
|
+
await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
|
|
673
|
+
} else {
|
|
674
|
+
reportUnrecognizedFlags(parsedArgs, resolved.extensionFlags);
|
|
675
|
+
for (const [flagName, value] of parsedArgs.unknownFlags) {
|
|
676
|
+
preloadedExtensions?.runtime.flagValues.set(registeredFlagNames.get(flagName) ?? flagName, value);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
610
679
|
|
|
611
680
|
const notifs: (InteractiveModeNotify | null)[] = [];
|
|
612
681
|
|
|
@@ -655,17 +724,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
655
724
|
}
|
|
656
725
|
|
|
657
726
|
const cwd = getProjectDir();
|
|
658
|
-
const sandboxOverrides: Partial<Record<SettingPath, unknown>> = {};
|
|
659
|
-
if (parsedArgs.noSandbox) sandboxOverrides["sandbox.enabled"] = false;
|
|
660
|
-
if (parsedArgs.noMemories) sandboxOverrides["memories.enabled"] = false;
|
|
661
|
-
if (parsedArgs.allowPath?.length) {
|
|
662
|
-
sandboxOverrides["sandbox.allowRead"] = parsedArgs.allowPath;
|
|
663
|
-
sandboxOverrides["sandbox.allowWrite"] = parsedArgs.allowPath;
|
|
664
|
-
}
|
|
665
|
-
await logger.time("settings:init", Settings.init, {
|
|
666
|
-
cwd,
|
|
667
|
-
overrides: Object.keys(sandboxOverrides).length > 0 ? sandboxOverrides : undefined,
|
|
668
|
-
});
|
|
669
727
|
|
|
670
728
|
// F5 XC context is session-scoped: nothing loads at startup. We still init the
|
|
671
729
|
// ContextService singleton so /context commands and the session bootstrap work.
|
|
@@ -712,9 +770,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
712
770
|
const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
|
|
713
771
|
const mode = parsedArgs.mode || "text";
|
|
714
772
|
|
|
715
|
-
// Initialize discovery system with settings for provider persistence
|
|
716
|
-
logger.time("initializeWithSettings");
|
|
717
|
-
initializeWithSettings(settings);
|
|
718
773
|
modelRegistry.refreshInBackground();
|
|
719
774
|
|
|
720
775
|
// Apply model role overrides from CLI args or env vars (ephemeral, not persisted)
|
|
@@ -807,14 +862,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
807
862
|
})();
|
|
808
863
|
}
|
|
809
864
|
|
|
810
|
-
// Wire --plugin-dir and preload plugin roots for sync consumers (LSP config)
|
|
811
|
-
const home = os.homedir();
|
|
812
|
-
if (parsedArgs.pluginDirs && parsedArgs.pluginDirs.length > 0) {
|
|
813
|
-
await logger.time("injectPluginDirRoots", injectPluginDirRoots, home, parsedArgs.pluginDirs!, getProjectDir());
|
|
814
|
-
} else {
|
|
815
|
-
await logger.time("preloadPluginRoots", preloadPluginRoots, home, getProjectDir());
|
|
816
|
-
}
|
|
817
|
-
|
|
818
865
|
// Background marketplace update notification (non-blocking).
|
|
819
866
|
if (autoUpdate === "notify") {
|
|
820
867
|
void (async () => {
|
|
@@ -853,6 +900,10 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
853
900
|
sessionOptions.authStorage = authStorage;
|
|
854
901
|
sessionOptions.modelRegistry = modelRegistry;
|
|
855
902
|
sessionOptions.hasUI = isInteractive;
|
|
903
|
+
if (preloadedExtensions && extensionEventBus) {
|
|
904
|
+
sessionOptions.preloadedExtensions = preloadedExtensions;
|
|
905
|
+
sessionOptions.eventBus = extensionEventBus;
|
|
906
|
+
}
|
|
856
907
|
|
|
857
908
|
// Handle CLI --api-key as runtime override (not persisted)
|
|
858
909
|
if (parsedArgs.apiKey) {
|
|
@@ -980,18 +1031,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
980
1031
|
notifs.push({ kind: "error", message: modelRegistryError.message });
|
|
981
1032
|
}
|
|
982
1033
|
|
|
983
|
-
// Re-parse CLI args now that extension flags are known, and apply their values. The bootstrap
|
|
984
|
-
// parse recorded each unclaimed flag with any value it consumed, so this only has to hand those
|
|
985
|
-
// values over — and whatever no extension claims is a genuine unknown flag.
|
|
986
|
-
{
|
|
987
|
-
const extFlags = session.extensionRunner?.getFlags();
|
|
988
|
-
const claimed = parseArgs(rawArgs, extFlags).unknownFlags;
|
|
989
|
-
for (const [flagName, value] of claimed) {
|
|
990
|
-
session.extensionRunner?.setFlagValue(flagName, value);
|
|
991
|
-
}
|
|
992
|
-
reportUnrecognizedFlags(parsedArgs, extFlags);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
1034
|
if (!isInteractive && !session.model) {
|
|
996
1035
|
if (modelFallbackMessage) {
|
|
997
1036
|
process.stderr.write(`${chalk.red(modelFallbackMessage)}\n`);
|
|
@@ -1008,8 +1047,11 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
1008
1047
|
const createAcpSession = async (cwd: string) => {
|
|
1009
1048
|
const nextSettings = await session.settings.cloneForCwd(cwd);
|
|
1010
1049
|
const nextSessionManager = SessionManager.create(cwd, parsedArgs.sessionDir);
|
|
1050
|
+
const nextSessionOptions = { ...sessionOptions };
|
|
1051
|
+
delete nextSessionOptions.preloadedExtensions;
|
|
1052
|
+
delete nextSessionOptions.eventBus;
|
|
1011
1053
|
const { session: nextSession } = await createAgentSession({
|
|
1012
|
-
...
|
|
1054
|
+
...nextSessionOptions,
|
|
1013
1055
|
cwd,
|
|
1014
1056
|
sessionManager: nextSessionManager,
|
|
1015
1057
|
settings: nextSettings,
|
|
@@ -674,7 +674,7 @@ export class AcpAgent implements Agent {
|
|
|
674
674
|
|
|
675
675
|
#buildThinkingOptions(session: AgentSession): Array<{ value: string; name: string; description?: string }> {
|
|
676
676
|
return [
|
|
677
|
-
{ value: THINKING_OFF, name: "Off" },
|
|
677
|
+
...(session.model?.thinking?.canDisable === false ? [] : [{ value: THINKING_OFF, name: "Off" }]),
|
|
678
678
|
...session.getAvailableThinkingLevels().map(level => ({
|
|
679
679
|
value: level,
|
|
680
680
|
name: level,
|