@f5-sales-demo/xcsh 20.1.2 → 20.2.3

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.
@@ -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 (contract 1.10.0): Office sends its
484
- // lowercased Office.context.host ("excel"|"powerpoint"|"word"); the Chrome
485
- // extension omits it → clientHost stays null → the browser profile. Invalid
486
- // values are ignored (null retained). Echoed back so the client can confirm.
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 {
@@ -0,0 +1,2 @@
1
+ /** Public Chrome Web Store identity used by the bridge origin allowlist. */
2
+ export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
@@ -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. Warns (does
106
- * not fail) on mismatch — a co-built pair stays in lockstep, while a hand-run
107
- * mismatch degrades gracefully. Falls back to `ping` for an extension that
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
- let liveContractVersion: string | undefined;
112
- try {
113
- const caps = unwrap(await server.request("capabilities", {}), "capabilities") as {
114
- contractVersion?: string;
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
- console.warn(`[xcsh] Chrome extension capability mismatch (${check.severity}): ${check.message}`);
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
- // Auto-login: if XCSH_USERNAME + XCSH_CONSOLE_PASSWORD are available (set by
359
- // ContextService from the context's env map), login automatically — the login
360
- // tool handles "already authenticated" (instant return) so calling it every
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: use the login tool to log in. The login tool handles ALL environments — production (*.console.ves.volterra.io) AND staging (*.staging.volterra.us, login-staging.volterra.us). Do NOT claim the login tool is broken, unsupported, or doesn't work for staging — it does.
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.
@@ -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
@@ -13,7 +13,7 @@
13
13
  * `cli/args.ts` can both depend on it without a cycle.
14
14
  */
15
15
  import { THINKING_EFFORTS } from "@f5-sales-demo/pi-ai";
16
- import { type FlagDescriptor, Flags } from "@f5-sales-demo/pi-utils/cli";
16
+ import { CliUsageError, type FlagDescriptor, Flags } from "@f5-sales-demo/pi-utils/cli";
17
17
 
18
18
  export type FlagArity = "boolean" | "value" | "optional-value" | "repeatable-value";
19
19
 
@@ -157,8 +157,6 @@ export interface UnrecognizedFlag {
157
157
  name: string;
158
158
  }
159
159
 
160
- export class FlagUsageError extends Error {}
161
-
162
160
  /**
163
161
  * Rewrite `--name=value` into `["--name", "value"]` for every flag that takes a value.
164
162
  *
@@ -197,7 +195,7 @@ export function normalizeFlagTokens(
197
195
  const spec = flagSpec(name);
198
196
  if (spec) {
199
197
  if (!takesValue(spec)) {
200
- throw new FlagUsageError(`--${name} is a boolean flag and does not take a value`);
198
+ throw new CliUsageError(`--${name} is a boolean flag and does not take a value`);
201
199
  }
202
200
  normalized.push(`--${name}`, value);
203
201
  continue;
@@ -206,7 +204,7 @@ export function normalizeFlagTokens(
206
204
  const extension = extensionFlags?.get(name);
207
205
  if (extension) {
208
206
  if (extension.type === "boolean") {
209
- throw new FlagUsageError(`--${name} is a boolean flag and does not take a value`);
207
+ throw new CliUsageError(`--${name} is a boolean flag and does not take a value`);
210
208
  }
211
209
  normalized.push(`--${name}`, value);
212
210
  continue;
@@ -0,0 +1,134 @@
1
+ import type { FlagDescriptor } from "@f5-sales-demo/pi-utils/cli";
2
+ import { flagNameForChar, flagSpec, type LaunchFlagName } from "./flag-spec";
3
+
4
+ export interface PrefixedCommandRoute {
5
+ command: string;
6
+ commandArgs: string[];
7
+ prefixFlags: LaunchFlagName[];
8
+ }
9
+
10
+ function uniqueFlags(flags: readonly LaunchFlagName[]): LaunchFlagName[] {
11
+ return [...new Set(flags)];
12
+ }
13
+
14
+ /**
15
+ * Find agent-launch flags in a registered command's argv while preserving that command's own flags.
16
+ *
17
+ * `--` ends option parsing. Long and short command flags both take precedence over identically named
18
+ * launch flags, so `sandbox check -v` remains the sandbox check's verbose mode.
19
+ */
20
+ export function findCommandLaunchFlags(
21
+ argv: readonly string[],
22
+ commandFlags: Readonly<Record<string, FlagDescriptor>>,
23
+ ): LaunchFlagName[] {
24
+ const shortCommandFlags = new Set(
25
+ Object.values(commandFlags)
26
+ .map(descriptor => descriptor.char)
27
+ .filter((char): char is string => char !== undefined),
28
+ );
29
+ const found: LaunchFlagName[] = [];
30
+
31
+ for (const token of argv) {
32
+ if (token === "--") break;
33
+ if (token.startsWith("--")) {
34
+ const name = token.slice(2).split("=", 1)[0];
35
+ if (commandFlags[name] !== undefined) continue;
36
+ if (flagSpec(name) !== undefined) found.push(name as LaunchFlagName);
37
+ continue;
38
+ }
39
+ if (!token.startsWith("-") || token === "-") continue;
40
+ const char = token.slice(1);
41
+ if (shortCommandFlags.has(char)) continue;
42
+ const name = flagNameForChar(char);
43
+ if (name !== undefined) found.push(name);
44
+ }
45
+
46
+ return uniqueFlags(found);
47
+ }
48
+
49
+ /** Render the single scope diagnostic used for launch flags on either side of a subcommand. */
50
+ export function launchFlagScopeMessage(
51
+ flags: readonly LaunchFlagName[],
52
+ command: string,
53
+ commandArgs: readonly string[],
54
+ bin: string,
55
+ ): string {
56
+ const unique = uniqueFlags(flags);
57
+ const names = unique.map(flag => `--${flag}`).join(", ");
58
+ const singular = unique.length === 1;
59
+ if (command === "sandbox" && commandArgs[0] === "check") {
60
+ return (
61
+ `Launch ${singular ? "flag" : "flags"} ${names} ${singular ? "applies" : "apply"} to an agent session, not to \`sandbox check\`. ` +
62
+ `Run \`${bin} sandbox check\` without launch flags; its \`explicit grant restores parent enumeration\` probe verifies grant behavior.`
63
+ );
64
+ }
65
+ return (
66
+ `Launch ${singular ? "flag" : "flags"} ${names} ${singular ? "applies" : "apply"} to an agent session, not to the ` +
67
+ `\`${command}\` subcommand. Start an agent session with launch flags before its prompt.`
68
+ );
69
+ }
70
+
71
+ function optionalValue(token: string | undefined): token is string {
72
+ return token !== undefined && !token.startsWith("-") && !token.startsWith("@");
73
+ }
74
+
75
+ /**
76
+ * Find a registered command after root launch flags without changing their scope.
77
+ *
78
+ * Root flags configure an agent launch. They are not global options for every command, so callers use
79
+ * this result to report the scope error instead of treating the command and its arguments as prompt
80
+ * text. `--` deliberately stops command discovery and keeps everything after it as launch content.
81
+ */
82
+ export function findPrefixedCommand(
83
+ argv: readonly string[],
84
+ isCommand: (token: string) => boolean,
85
+ ): PrefixedCommandRoute | undefined {
86
+ const prefixFlags: LaunchFlagName[] = [];
87
+ let index = 0;
88
+ while (index < argv.length) {
89
+ const token = argv[index];
90
+ if (token === "--") return undefined;
91
+ if (!token.startsWith("-") || token === "-") {
92
+ if (prefixFlags.length === 0 || !isCommand(token)) return undefined;
93
+ return {
94
+ command: token,
95
+ commandArgs: argv.slice(index + 1),
96
+ prefixFlags,
97
+ };
98
+ }
99
+
100
+ const [longName, inlineValue] = token.startsWith("--") ? token.slice(2).split("=", 2) : [undefined, undefined];
101
+ const name = longName ?? flagNameForChar(token.slice(1));
102
+ const spec = name === undefined ? undefined : flagSpec(name);
103
+ if (name === undefined || spec === undefined) return undefined;
104
+ prefixFlags.push(name as LaunchFlagName);
105
+
106
+ // Keep invalid boolean `=value` forms on the launch parser's diagnostic path instead of
107
+ // replacing that syntax error with a subcommand-scope error.
108
+ if (spec.arity === "boolean") {
109
+ if (inlineValue !== undefined) return undefined;
110
+ index++;
111
+ continue;
112
+ }
113
+ if (inlineValue !== undefined) {
114
+ index++;
115
+ continue;
116
+ }
117
+ // Optional values and subcommands are otherwise ambiguous. A registered command wins; an
118
+ // operator who means the same token as a value can state that unambiguously with `=`.
119
+ if (spec.arity === "optional-value" && isCommand(argv[index + 1] ?? "")) {
120
+ return {
121
+ command: argv[index + 1],
122
+ commandArgs: argv.slice(index + 2),
123
+ prefixFlags,
124
+ };
125
+ }
126
+ if (spec.arity === "optional-value") {
127
+ index += optionalValue(argv[index + 1]) ? 2 : 1;
128
+ continue;
129
+ }
130
+ if (argv[index + 1] === undefined) return undefined;
131
+ index += 2;
132
+ }
133
+ return undefined;
134
+ }
@@ -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,7 @@ 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 { findPrefixedCommand, launchFlagScopeMessage } from "./cli/root-command-routing";
8
9
  import { locales } from "./locales/index";
9
10
 
10
11
  registerLocales(locales);
@@ -89,11 +90,44 @@ function isSubcommand(first: string | undefined): boolean {
89
90
  return commands.some(e => e.name === first || e.aliases?.includes(first));
90
91
  }
91
92
 
93
+ function requestsHelp(args: readonly string[]): boolean {
94
+ for (const arg of args) {
95
+ if (arg === "--") return false;
96
+ if (arg === "--help" || arg === "-h") return true;
97
+ }
98
+ return false;
99
+ }
100
+
92
101
  /** Run the CLI with the given argv (no `process.argv` prefix). */
93
102
  export function runCli(argv: string[]): Promise<void> {
94
103
  // --help and --version are handled by run() directly, don't rewrite those.
95
104
  // Everything else that isn't a known subcommand routes to "launch".
96
105
  const first = argv[0];
106
+ const prefixedCommand = findPrefixedCommand(argv, token => isSubcommand(token));
107
+ if (
108
+ prefixedCommand !== undefined &&
109
+ !prefixedCommand.prefixFlags.some(flag => flag === "help" || flag === "version")
110
+ ) {
111
+ if (requestsHelp(prefixedCommand.commandArgs)) {
112
+ return run({
113
+ bin: APP_NAME,
114
+ version: VERSION,
115
+ argv: [prefixedCommand.command, ...prefixedCommand.commandArgs],
116
+ commands,
117
+ help: showHelp,
118
+ });
119
+ }
120
+ process.stderr.write(
121
+ `Error: ${launchFlagScopeMessage(
122
+ prefixedCommand.prefixFlags,
123
+ prefixedCommand.command,
124
+ prefixedCommand.commandArgs,
125
+ APP_NAME,
126
+ )}\n`,
127
+ );
128
+ process.exitCode = 2;
129
+ return Promise.resolve();
130
+ }
97
131
  const runArgv =
98
132
  // Chrome launches the native-messaging host with the calling extension's
99
133
  // origin (chrome-extension://…/) as the first arg. Route that to the
@@ -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
 
@@ -1,5 +1,7 @@
1
1
  /** Run installed-binary sandbox diagnostics. */
2
- import { Args, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
2
+ import { APP_NAME } from "@f5-sales-demo/pi-utils";
3
+ import { Args, CliUsageError, Command, Flags } from "@f5-sales-demo/pi-utils/cli";
4
+ import { findCommandLaunchFlags, launchFlagScopeMessage } from "../cli/root-command-routing";
3
5
  import { runSandboxCheck } from "../cli/sandbox-check";
4
6
 
5
7
  export default class Sandbox extends Command {
@@ -19,6 +21,10 @@ export default class Sandbox extends Command {
19
21
  };
20
22
 
21
23
  async run(): Promise<void> {
24
+ const launchFlags = findCommandLaunchFlags(this.argv, Sandbox.flags);
25
+ if (launchFlags.length > 0) {
26
+ throw new CliUsageError(launchFlagScopeMessage(launchFlags, "sandbox", this.argv, APP_NAME));
27
+ }
22
28
  const { flags } = await this.parse(Sandbox);
23
29
  const report = await runSandboxCheck({ json: flags.json, verbose: flags.verbose });
24
30
  if (report.summary.failed > 0 || report.summary.errors > 0) process.exitCode = 1;
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "20.1.2",
21
- "commit": "b5534d98c8ad483cc6e8709b44242cf3ea98041d",
22
- "shortCommit": "b5534d9",
20
+ "version": "20.2.3",
21
+ "commit": "7f2e292bf69f563ff32c24c4faa787538aaab420",
22
+ "shortCommit": "7f2e292",
23
23
  "branch": "main",
24
- "tag": "v20.1.2",
25
- "commitDate": "2026-08-01T21:09:01Z",
26
- "buildDate": "2026-08-01T21:30:18.970Z",
24
+ "tag": "v20.2.3",
25
+ "commitDate": "2026-08-02T03:00:43Z",
26
+ "buildDate": "2026-08-02T03:26:38.179Z",
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/b5534d98c8ad483cc6e8709b44242cf3ea98041d",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.1.2"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/7f2e292bf69f563ff32c24c4faa787538aaab420",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v20.2.3"
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
- const normalizePath = (value: string) => {
325
- const resolved = realpathSync(path.resolve(value));
326
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
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 && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) {
357
+ if (fallback && normalizePathForComparison(fallback) !== cwd && (await isDirectory(fallback))) {
360
358
  setProjectDir(fallback);
361
359
  }
362
360
  } catch {