@f5-sales-demo/xcsh 20.1.2 → 20.2.2
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 +44 -28
- package/package.json +8 -8
- package/src/browser/capabilities.generated.ts +5 -72
- package/src/browser/capabilities.json +3 -51
- package/src/browser/chat-conformance.json +120 -229
- package/src/browser/chat-handler.ts +20 -18
- package/src/browser/chat-protocol.ts +23 -19
- package/src/browser/extension-bridge-tools.ts +0 -1
- package/src/browser/extension-bridge.ts +6 -9
- package/src/browser/extension-identity.ts +2 -0
- package/src/browser/extension-provider.ts +11 -54
- package/src/browser/host-profiles.ts +1 -1
- package/src/cli/chrome-cli.ts +3 -2
- package/src/cli/root-command-routing.ts +72 -0
- package/src/cli/sandbox-check.ts +43 -2
- package/src/cli.ts +39 -1
- package/src/commands/manager.ts +8 -8
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +6 -8
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Server, ServerWebSocket } from "bun";
|
|
3
3
|
import { LOCALIP_HOST } from "./bridge-cert";
|
|
4
|
+
import { EXTENSION_ID } from "./extension-identity";
|
|
4
5
|
import { type ClientHost, isClientHost } from "./host-profiles";
|
|
5
6
|
|
|
6
7
|
export interface ToolResult {
|
|
@@ -189,8 +190,6 @@ export const ADDIN_ALLOWED_ORIGIN_SUFFIXES = ["local-ip.sh"] as const;
|
|
|
189
190
|
*/
|
|
190
191
|
export function isAllowedBridgeOrigin(origin: string | null | undefined): boolean {
|
|
191
192
|
if (!origin) return false;
|
|
192
|
-
// Lazy require avoids a top-level import cycle (chrome-cli imports this module).
|
|
193
|
-
const { EXTENSION_ID } = require("../cli/chrome-cli") as { EXTENSION_ID: string };
|
|
194
193
|
if (origin === `chrome-extension://${EXTENSION_ID}`) return true;
|
|
195
194
|
let url: URL;
|
|
196
195
|
try {
|
|
@@ -403,7 +402,6 @@ export class BridgeServer {
|
|
|
403
402
|
listen(port: number, opts?: BridgeListenOpts): boolean {
|
|
404
403
|
// Extract the fetch + websocket handlers to locals so BOTH the ws and the wss
|
|
405
404
|
// listeners share ONE implementation (DRY). Behavior is unchanged from before.
|
|
406
|
-
const { EXTENSION_ID } = require("../cli/chrome-cli") as { EXTENSION_ID: string };
|
|
407
405
|
const fetch = (req: Request, server: Server<undefined>): Response | undefined => {
|
|
408
406
|
const origin = req.headers.get("origin");
|
|
409
407
|
// SUPERSET gate: the Chrome ext origin stays allowed (Chrome path preserved);
|
|
@@ -480,11 +478,10 @@ export class BridgeServer {
|
|
|
480
478
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
481
479
|
} else if (msg.type === "hello") {
|
|
482
480
|
// Identity handshake: tell the extension which tenant this process serves.
|
|
483
|
-
// Record the announced client host
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
if (isClientHost(msg.host)) this.#clientHost = msg.host;
|
|
481
|
+
// Record the announced client host only on the server-owned Office bridge.
|
|
482
|
+
// A browser client cannot gain Office routing or provider configuration by
|
|
483
|
+
// claiming an Office host in an untrusted hello frame.
|
|
484
|
+
this.#clientHost = this.#serveKind === "office" && isClientHost(msg.host) ? msg.host : null;
|
|
488
485
|
const info = this.#sessionInfo?.() ?? {
|
|
489
486
|
tenant: null,
|
|
490
487
|
env: null,
|
|
@@ -506,7 +503,7 @@ export class BridgeServer {
|
|
|
506
503
|
serveKind: this.#serveKind,
|
|
507
504
|
pid: process.pid,
|
|
508
505
|
wssPort: this.wssPort,
|
|
509
|
-
canConfigureProvider: true,
|
|
506
|
+
...(this.#serveKind === "office" ? { canConfigureProvider: true } : {}),
|
|
510
507
|
}),
|
|
511
508
|
);
|
|
512
509
|
} else {
|
|
@@ -24,11 +24,6 @@ export function resolveRef(tree: AxRefNode, selector: string): string {
|
|
|
24
24
|
/** Thin wrapper over the bridge for the page-level operations the agent needs. */
|
|
25
25
|
export interface ExtensionPage {
|
|
26
26
|
navigate(url: string): Promise<void>;
|
|
27
|
-
login(
|
|
28
|
-
email: string,
|
|
29
|
-
password: string,
|
|
30
|
-
consoleUrl: string,
|
|
31
|
-
): Promise<{ loggedIn: boolean; finalUrl: string; steps: string[] }>;
|
|
32
27
|
readAx(): Promise<AxRefNode>;
|
|
33
28
|
click(ref: string): Promise<void>;
|
|
34
29
|
screenshot(): Promise<string>;
|
|
@@ -102,25 +97,17 @@ function unwrap(result: ToolResult, tool: string): unknown {
|
|
|
102
97
|
|
|
103
98
|
/**
|
|
104
99
|
* Connect-time discovery handshake: confirm the extension is live and that its
|
|
105
|
-
* published capability contract matches what xcsh was built against.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* predates the `capabilities` tool.
|
|
100
|
+
* published capability contract exactly matches what xcsh was built against.
|
|
101
|
+
* Contract 2 is a prerelease clean break: an absent or mismatched manifest fails
|
|
102
|
+
* closed instead of falling back to a legacy liveness-only handshake.
|
|
109
103
|
*/
|
|
110
104
|
async function handshakeCapabilities(server: BridgeServer): Promise<void> {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
};
|
|
116
|
-
liveContractVersion = caps?.contractVersion;
|
|
117
|
-
} catch {
|
|
118
|
-
// Older extension without the `capabilities` tool — at least confirm liveness.
|
|
119
|
-
unwrap(await server.request("ping", {}), "ping");
|
|
120
|
-
}
|
|
121
|
-
const check = checkContractVersion(liveContractVersion, EXTENSION_CONTRACT_VERSION);
|
|
105
|
+
const caps = unwrap(await server.request("capabilities", {}), "capabilities") as {
|
|
106
|
+
contractVersion?: string;
|
|
107
|
+
};
|
|
108
|
+
const check = checkContractVersion(caps?.contractVersion, EXTENSION_CONTRACT_VERSION);
|
|
122
109
|
if (!check.ok) {
|
|
123
|
-
|
|
110
|
+
throw new Error(`Chrome extension capability mismatch (${check.severity}): ${check.message}`);
|
|
124
111
|
}
|
|
125
112
|
}
|
|
126
113
|
|
|
@@ -135,27 +122,6 @@ class BridgeExtensionPage implements ExtensionPage {
|
|
|
135
122
|
unwrap(await this.#server.request("navigate", { url }), "navigate");
|
|
136
123
|
}
|
|
137
124
|
|
|
138
|
-
async login(
|
|
139
|
-
email: string,
|
|
140
|
-
password: string,
|
|
141
|
-
consoleUrl: string,
|
|
142
|
-
): Promise<{ loggedIn: boolean; finalUrl: string; steps: string[] }> {
|
|
143
|
-
// Defense-in-depth: validate the console URL before sending credentials
|
|
144
|
-
// over the bridge — only https F5 XC console domains are allowed, so a
|
|
145
|
-
// bad consoleUrl can never carry credentials to a foreign host.
|
|
146
|
-
const parsed = new URL(consoleUrl); // throws on malformed
|
|
147
|
-
if (parsed.protocol !== "https:") {
|
|
148
|
-
throw new Error(`login: consoleUrl must use https, got ${parsed.protocol}`);
|
|
149
|
-
}
|
|
150
|
-
if (!/\.volterra\.us$|\.console\.ves\.volterra\.io$/.test(parsed.hostname)) {
|
|
151
|
-
throw new Error(`login: consoleUrl host "${parsed.hostname}" is not an allowed F5 XC console domain`);
|
|
152
|
-
}
|
|
153
|
-
return unwrap(
|
|
154
|
-
await this.#server.request("login", { email, password, consoleUrl: parsed.toString() }, 90_000),
|
|
155
|
-
"login",
|
|
156
|
-
) as { loggedIn: boolean; finalUrl: string; steps: string[] };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
125
|
async readAx(): Promise<AxRefNode> {
|
|
160
126
|
return unwrap(await this.#server.request("read_ax", {}), "read_ax") as AxRefNode;
|
|
161
127
|
}
|
|
@@ -355,18 +321,9 @@ export class ExtensionBrowserProvider implements BrowserProvider {
|
|
|
355
321
|
|
|
356
322
|
const page: ExtensionPage = new BridgeExtensionPage(server);
|
|
357
323
|
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
// time is cheap and guarantees a valid session. Falls back to navigate-only
|
|
362
|
-
// (co-drive) if credentials aren't available.
|
|
363
|
-
const email = process.env.XCSH_USERNAME;
|
|
364
|
-
const password = process.env.XCSH_CONSOLE_PASSWORD;
|
|
365
|
-
if (email && password) {
|
|
366
|
-
await page.login(email, password, consoleUrl);
|
|
367
|
-
} else {
|
|
368
|
-
unwrap(await server.request("navigate", { url: consoleUrl }), "navigate");
|
|
369
|
-
}
|
|
324
|
+
// Authentication remains an operator-owned browser action. The worker never
|
|
325
|
+
// reads or forwards usernames/passwords across the extension bridge.
|
|
326
|
+
unwrap(await server.request("navigate", { url: consoleUrl }), "navigate");
|
|
370
327
|
|
|
371
328
|
return {
|
|
372
329
|
page: new ExtensionPageActions(page),
|
|
@@ -57,7 +57,7 @@ BEHAVIOR:
|
|
|
57
57
|
- You KNOW which page the user is on (injected below). Don't ask "what page are you on?" — tell them.
|
|
58
58
|
- For questions about the page/resource: answer from the injected context. No tools.
|
|
59
59
|
- If a blocking popup/survey appears, dismiss it by clicking the close button.
|
|
60
|
-
- If on the LOGIN page:
|
|
60
|
+
- If on the LOGIN page: ask the user to authenticate directly in the browser. Never request, accept, or enter a username, password, token, or other authentication secret.
|
|
61
61
|
|
|
62
62
|
BROWSER AUTOMATION (when the user asks to create/modify/navigate resources):
|
|
63
63
|
- You are IN a Chrome browser. The active console tab is your workspace — use IT.
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -11,8 +11,11 @@ import { homedir } from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { acquirePage, type BrowserProviderStatus, CdpBrowserProvider } from "../browser";
|
|
13
13
|
import { PORT_RANGE_END, PORT_RANGE_START, resolveForcedPort } from "../browser/extension-bridge";
|
|
14
|
+
import { EXTENSION_ID } from "../browser/extension-identity";
|
|
14
15
|
import { installNativeHost } from "../services/native-host-install";
|
|
15
16
|
|
|
17
|
+
export * from "../browser/extension-identity";
|
|
18
|
+
|
|
16
19
|
/** Ask a running manager to step down (control-socket `shutdown` frame). Resolves
|
|
17
20
|
* true if a manager answered the socket, false if none was running. Best-effort. */
|
|
18
21
|
async function requestManagerShutdown(reason: "updated"): Promise<boolean> {
|
|
@@ -32,8 +35,6 @@ type Settings = { get(key: string): unknown };
|
|
|
32
35
|
|
|
33
36
|
export type ChromeAction = "status" | "relaunch" | "setup" | "install-host" | "recycle";
|
|
34
37
|
|
|
35
|
-
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
36
|
-
|
|
37
38
|
/**
|
|
38
39
|
* Baked-in Chrome Web Store URL for the xcsh console-automation extension.
|
|
39
40
|
* Surfaced to the user when the extension is not installed/connected so they
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { flagNameForChar, flagSpec, type LaunchFlagName } from "./flag-spec";
|
|
2
|
+
|
|
3
|
+
export interface PrefixedCommandRoute {
|
|
4
|
+
command: string;
|
|
5
|
+
commandArgs: string[];
|
|
6
|
+
prefixFlags: LaunchFlagName[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function optionalValue(token: string | undefined): token is string {
|
|
10
|
+
return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find a registered command after root launch flags without changing their scope.
|
|
15
|
+
*
|
|
16
|
+
* Root flags configure an agent launch. They are not global options for every command, so callers use
|
|
17
|
+
* this result to report the scope error instead of treating the command and its arguments as prompt
|
|
18
|
+
* text. `--` deliberately stops command discovery and keeps everything after it as launch content.
|
|
19
|
+
*/
|
|
20
|
+
export function findPrefixedCommand(
|
|
21
|
+
argv: readonly string[],
|
|
22
|
+
isCommand: (token: string) => boolean,
|
|
23
|
+
): PrefixedCommandRoute | undefined {
|
|
24
|
+
const prefixFlags: LaunchFlagName[] = [];
|
|
25
|
+
let index = 0;
|
|
26
|
+
while (index < argv.length) {
|
|
27
|
+
const token = argv[index];
|
|
28
|
+
if (token === "--") return undefined;
|
|
29
|
+
if (!token.startsWith("-") || token === "-") {
|
|
30
|
+
if (prefixFlags.length === 0 || !isCommand(token)) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
command: token,
|
|
33
|
+
commandArgs: argv.slice(index + 1),
|
|
34
|
+
prefixFlags,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [longName, inlineValue] = token.startsWith("--") ? token.slice(2).split("=", 2) : [undefined, undefined];
|
|
39
|
+
const name = longName ?? flagNameForChar(token.slice(1));
|
|
40
|
+
const spec = name === undefined ? undefined : flagSpec(name);
|
|
41
|
+
if (name === undefined || spec === undefined) return undefined;
|
|
42
|
+
prefixFlags.push(name as LaunchFlagName);
|
|
43
|
+
|
|
44
|
+
// Keep invalid boolean `=value` forms on the launch parser's diagnostic path instead of
|
|
45
|
+
// replacing that syntax error with a subcommand-scope error.
|
|
46
|
+
if (spec.arity === "boolean") {
|
|
47
|
+
if (inlineValue !== undefined) return undefined;
|
|
48
|
+
index++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (inlineValue !== undefined) {
|
|
52
|
+
index++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Optional values and subcommands are otherwise ambiguous. A registered command wins; an
|
|
56
|
+
// operator who means the same token as a value can state that unambiguously with `=`.
|
|
57
|
+
if (spec.arity === "optional-value" && isCommand(argv[index + 1] ?? "")) {
|
|
58
|
+
return {
|
|
59
|
+
command: argv[index + 1],
|
|
60
|
+
commandArgs: argv.slice(index + 2),
|
|
61
|
+
prefixFlags,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (spec.arity === "optional-value") {
|
|
65
|
+
index += optionalValue(argv[index + 1]) ? 2 : 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (argv[index + 1] === undefined) return undefined;
|
|
69
|
+
index += 2;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
package/src/cli/sandbox-check.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { executeShell } from "@f5-sales-demo/pi-natives";
|
|
5
|
+
import { executeShell, fencePermits } from "@f5-sales-demo/pi-natives";
|
|
6
6
|
import { isEnoent } from "@f5-sales-demo/pi-utils";
|
|
7
7
|
import { Settings } from "../config/settings";
|
|
8
8
|
import { fenceForNative } from "../exec/bash-executor";
|
|
9
|
-
import { buildContainmentFence, type ContainmentFence, containmentStatus } from "../sandbox/containment";
|
|
9
|
+
import { buildContainmentFence, type ContainmentFence, containmentStatus, fenceVerdict } from "../sandbox/containment";
|
|
10
10
|
import { evaluateToolCall } from "../sandbox/enforce";
|
|
11
11
|
import {
|
|
12
12
|
SANDBOX_CHECK_NAMED_SIBLING_ENV,
|
|
@@ -364,6 +364,46 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
364
364
|
redactions,
|
|
365
365
|
);
|
|
366
366
|
});
|
|
367
|
+
await check("explicit grant restores parent enumeration", async () => {
|
|
368
|
+
const grantedFence = buildContainmentFence({
|
|
369
|
+
workspace,
|
|
370
|
+
home: operatorHome,
|
|
371
|
+
fsRoot: fixtureRoot,
|
|
372
|
+
leakRoots: [sessionStore, memoryStore],
|
|
373
|
+
readOnlyRoots: [workspaces],
|
|
374
|
+
writeOnlyRoots: [workspaces],
|
|
375
|
+
});
|
|
376
|
+
const grantedNativeFence = fenceForNative(grantedFence);
|
|
377
|
+
if (
|
|
378
|
+
fenceVerdict(grantedFence, workspaces, "enumerate") !== "allow" ||
|
|
379
|
+
grantedNativeFence === undefined ||
|
|
380
|
+
!fencePermits(grantedNativeFence, workspaces, false, true)
|
|
381
|
+
) {
|
|
382
|
+
return {
|
|
383
|
+
passed: false,
|
|
384
|
+
detail:
|
|
385
|
+
"explicit grant was not accepted by both policy engines; path=<synthetic-session-parent>; errno=none",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// An inherited OS profile cannot be widened by a child. In that topology the live
|
|
390
|
+
// profile already grants this synthetic path through the workspace, so exercise the
|
|
391
|
+
// real operation there after both policy engines accepted the explicit grant. A
|
|
392
|
+
// standalone check applies the granted fence itself and covers the OS compiler too.
|
|
393
|
+
const result = await shellProbe(
|
|
394
|
+
`ls ${quote(workspaces)} > /dev/null`,
|
|
395
|
+
workspace,
|
|
396
|
+
inheritedProfile ? undefined : grantedFence,
|
|
397
|
+
abortController.signal,
|
|
398
|
+
);
|
|
399
|
+
return shellOutcome(
|
|
400
|
+
result,
|
|
401
|
+
true,
|
|
402
|
+
"explicit grant must restore synthetic session parent enumeration",
|
|
403
|
+
"<synthetic-session-parent>",
|
|
404
|
+
redactions,
|
|
405
|
+
);
|
|
406
|
+
});
|
|
367
407
|
await check("account container cannot be enumerated", async () => {
|
|
368
408
|
const result = await shellProbe(
|
|
369
409
|
`ls ${quote(accountRoot)} > /dev/null`,
|
|
@@ -422,6 +462,7 @@ export async function runSandboxCheck(options: SandboxCheckOptions = {}): Promis
|
|
|
422
462
|
} else {
|
|
423
463
|
for (const name of [
|
|
424
464
|
"session parent cannot be enumerated",
|
|
465
|
+
"explicit grant restores parent enumeration",
|
|
425
466
|
"account container cannot be enumerated",
|
|
426
467
|
"synthetic other account cannot be entered",
|
|
427
468
|
"cross-session stores cannot be read",
|
package/src/cli.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { APP_NAME, initI18n, MIN_BUN_VERSION, registerLocales, t, VERSION } from
|
|
|
5
5
|
* lightweight CLI runner from pi-utils.
|
|
6
6
|
*/
|
|
7
7
|
import { type CommandEntry, run } from "@f5-sales-demo/pi-utils/cli";
|
|
8
|
+
import { FlagUsageError } from "./cli/flag-spec";
|
|
9
|
+
import { findPrefixedCommand } from "./cli/root-command-routing";
|
|
8
10
|
import { locales } from "./locales/index";
|
|
9
11
|
|
|
10
12
|
registerLocales(locales);
|
|
@@ -89,11 +91,41 @@ function isSubcommand(first: string | undefined): boolean {
|
|
|
89
91
|
return commands.some(e => e.name === first || e.aliases?.includes(first));
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
function requestsHelp(args: readonly string[]): boolean {
|
|
95
|
+
for (const arg of args) {
|
|
96
|
+
if (arg === "--") return false;
|
|
97
|
+
if (arg === "--help" || arg === "-h") return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
/** Run the CLI with the given argv (no `process.argv` prefix). */
|
|
93
103
|
export function runCli(argv: string[]): Promise<void> {
|
|
94
104
|
// --help and --version are handled by run() directly, don't rewrite those.
|
|
95
105
|
// Everything else that isn't a known subcommand routes to "launch".
|
|
96
106
|
const first = argv[0];
|
|
107
|
+
const prefixedCommand = findPrefixedCommand(argv, token => isSubcommand(token));
|
|
108
|
+
if (
|
|
109
|
+
prefixedCommand !== undefined &&
|
|
110
|
+
!prefixedCommand.prefixFlags.some(flag => flag === "help" || flag === "version")
|
|
111
|
+
) {
|
|
112
|
+
if (requestsHelp(prefixedCommand.commandArgs)) {
|
|
113
|
+
return run({
|
|
114
|
+
bin: APP_NAME,
|
|
115
|
+
version: VERSION,
|
|
116
|
+
argv: [prefixedCommand.command, ...prefixedCommand.commandArgs],
|
|
117
|
+
commands,
|
|
118
|
+
help: showHelp,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const flags = [...new Set(prefixedCommand.prefixFlags)].map(flag => `--${flag}`).join(", ");
|
|
122
|
+
process.stderr.write(
|
|
123
|
+
`Error: launch ${prefixedCommand.prefixFlags.length === 1 ? "flag" : "flags"} ${flags} cannot precede the ` +
|
|
124
|
+
`\`${prefixedCommand.command}\` subcommand. Launch flags configure an agent session; subcommands must come first.\n`,
|
|
125
|
+
);
|
|
126
|
+
process.exitCode = 2;
|
|
127
|
+
return Promise.resolve();
|
|
128
|
+
}
|
|
97
129
|
const runArgv =
|
|
98
130
|
// Chrome launches the native-messaging host with the calling extension's
|
|
99
131
|
// origin (chrome-extension://…/) as the first arg. Route that to the
|
|
@@ -120,4 +152,10 @@ if (process.env.XCSH_SMOKE_TEST_SPECS === "1") {
|
|
|
120
152
|
process.exit(domainCount > 0 && categoryCount > 0 ? 0 : 1);
|
|
121
153
|
}
|
|
122
154
|
|
|
123
|
-
|
|
155
|
+
try {
|
|
156
|
+
await runCli(process.argv.slice(2));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!(error instanceof FlagUsageError)) throw error;
|
|
159
|
+
process.stderr.write(`Error: ${error.message}\n`);
|
|
160
|
+
process.exitCode = 2;
|
|
161
|
+
}
|
package/src/commands/manager.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { Command } from "@f5-sales-demo/pi-utils/cli";
|
|
|
26
26
|
// slows the manager's cold start — keep the daemon's module graph minimal).
|
|
27
27
|
import { VERSION } from "@f5-sales-demo/pi-utils/dirs";
|
|
28
28
|
import { portCandidates } from "../browser/extension-bridge";
|
|
29
|
+
import { EXTENSION_ID } from "../browser/extension-identity";
|
|
29
30
|
// Lean standalone fn (compiled-runtime detection) — no heavy graph, safe for the daemon.
|
|
30
31
|
import { detectCompiledRuntime } from "../internal-urls/build-info-runtime";
|
|
31
32
|
import { removeManagerState, writeManagerState } from "../services/manager-state";
|
|
@@ -88,8 +89,7 @@ function pidListeningOn(port: number): number {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
/** Complete the extension `hello` handshake against a bridge port (with the
|
|
91
|
-
* origin header the bridge requires), resolving the `hello_ack` frame or null.
|
|
92
|
-
* EXTENSION_ID is lazy-required so it stays off the manager's cold-start path. */
|
|
92
|
+
* origin header the bridge requires), resolving the `hello_ack` frame or null. */
|
|
93
93
|
function bridgeHello(port: number, timeoutMs = 400): Promise<Record<string, unknown> | null> {
|
|
94
94
|
const { promise, resolve } = Promise.withResolvers<Record<string, unknown> | null>();
|
|
95
95
|
let done = false;
|
|
@@ -100,7 +100,6 @@ function bridgeHello(port: number, timeoutMs = 400): Promise<Record<string, unkn
|
|
|
100
100
|
};
|
|
101
101
|
let ws: WebSocket;
|
|
102
102
|
try {
|
|
103
|
-
const { EXTENSION_ID } = require("../cli/chrome-cli");
|
|
104
103
|
// Intentionally ws://: the internal Chrome re-adoption client targets the
|
|
105
104
|
// bridge's local ws listener and does not cross the Office TLS boundary.
|
|
106
105
|
ws = new WebSocket(`ws://127.0.0.1:${port}`, {
|
|
@@ -528,6 +527,12 @@ export default class Manager extends Command {
|
|
|
528
527
|
},
|
|
529
528
|
};
|
|
530
529
|
|
|
530
|
+
// Finish startup reconciliation before publishing the control socket. A socket
|
|
531
|
+
// that accepts connections is the manager's readiness contract: clients send
|
|
532
|
+
// their first frame immediately, and a request/reply client can otherwise time
|
|
533
|
+
// out while the initial bridge scan is still loading and probing dependencies.
|
|
534
|
+
await readoptWorkers();
|
|
535
|
+
|
|
531
536
|
// Single-manager invariant + stale-socket reclamation. A live manager is
|
|
532
537
|
// never clobbered (we probe first and again on collision); a stale socket
|
|
533
538
|
// from a crashed/killed manager is reclaimed rather than crashing on
|
|
@@ -568,11 +573,6 @@ export default class Manager extends Command {
|
|
|
568
573
|
process.on("SIGTERM", () => gracefulShutdown("manual"));
|
|
569
574
|
process.on("SIGINT", () => gracefulShutdown("manual"));
|
|
570
575
|
|
|
571
|
-
// Zero-downtime handoff: re-adopt any bound workers a superseded manager left
|
|
572
|
-
// running BEFORE filling the pool (so their ports aren't mistaken for free).
|
|
573
|
-
// Awaited but bounded (~parallel 400ms); the socket already accepts connections.
|
|
574
|
-
await readoptWorkers();
|
|
575
|
-
|
|
576
576
|
// Pre-warm the spare pool so provisions can adopt instead of cold-spawn.
|
|
577
577
|
if (poolTarget > 0) maintainPool();
|
|
578
578
|
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "20.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "20.2.2",
|
|
21
|
+
"commit": "fd7d7d7d2303d3d17149a84b2e1ceb8b743d3a4a",
|
|
22
|
+
"shortCommit": "fd7d7d7",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v20.
|
|
25
|
-
"commitDate": "2026-08-
|
|
26
|
-
"buildDate": "2026-08-
|
|
24
|
+
"tag": "v20.2.2",
|
|
25
|
+
"commitDate": "2026-08-02T01:16:07Z",
|
|
26
|
+
"buildDate": "2026-08-02T01:43:23.755Z",
|
|
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/v20.
|
|
31
|
+
"commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/fd7d7d7d2303d3d17149a84b2e1ceb8b743d3a4a",
|
|
32
|
+
"releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.2.2"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -321,13 +321,11 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
|
|
|
321
321
|
return;
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const cwd = normalizePath(getProjectDir());
|
|
330
|
-
const normalizedHome = normalizePath(home);
|
|
324
|
+
// A nested xcsh process may inherit a profile that permits named home access but withholds parent
|
|
325
|
+
// metadata. Comparison is advisory auto-chdir logic, so an unavailable realpath must fall back to
|
|
326
|
+
// the resolved spelling instead of crashing before command dispatch (#2817).
|
|
327
|
+
const cwd = normalizePathForComparison(getProjectDir());
|
|
328
|
+
const normalizedHome = normalizePathForComparison(home);
|
|
331
329
|
if (cwd !== normalizedHome) {
|
|
332
330
|
return;
|
|
333
331
|
}
|
|
@@ -356,7 +354,7 @@ async function maybeAutoChdir(parsed: Args): Promise<void> {
|
|
|
356
354
|
|
|
357
355
|
try {
|
|
358
356
|
const fallback = os.tmpdir();
|
|
359
|
-
if (fallback &&
|
|
357
|
+
if (fallback && normalizePathForComparison(fallback) !== cwd && (await isDirectory(fallback))) {
|
|
360
358
|
setProjectDir(fallback);
|
|
361
359
|
}
|
|
362
360
|
} catch {
|