@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.
@@ -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
@@ -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
+ }
@@ -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
- await runCli(process.argv.slice(2));
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
+ }
@@ -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.1.2",
21
- "commit": "b5534d98c8ad483cc6e8709b44242cf3ea98041d",
22
- "shortCommit": "b5534d9",
20
+ "version": "20.2.2",
21
+ "commit": "fd7d7d7d2303d3d17149a84b2e1ceb8b743d3a4a",
22
+ "shortCommit": "fd7d7d7",
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.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/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/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
- 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 {