From c5139ae42d2714bd0d243469372a322f611eda7e Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 22 Sep 2026 19:06:08 -0700 Subject: [PATCH 1/2] fix(setup): keep WSL PATH scripts off the wsl.exe argv path Signed-off-by: Sebastien Tardif --- .../InstallGatewayServiceStep.cs | 4 +- .../MintBootstrapTokenStep.cs | 2 +- src/OpenClaw.SetupEngine/PairNodeStep.cs | 4 +- src/OpenClaw.SetupEngine/PairOperatorStep.cs | 4 +- src/OpenClaw.SetupEngine/StartGatewayStep.cs | 5 +- .../TailscaleSetupSteps.cs | 3 +- .../VerifyEndToEndStep.cs | 10 ++-- .../SetupStepsTests.cs | 46 +++++++++++++++++++ 8 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs b/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs index 4fedfe658..09df8056b 100644 --- a/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs +++ b/src/OpenClaw.SetupEngine/InstallGatewayServiceStep.cs @@ -20,7 +20,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati var distro = ctx.DistroName!; var result = await ctx.Commands.RunInWslAsync( - distro, $"{ctx.WslPathPrefix} && openclaw gateway install --force", TimeSpan.FromSeconds(60), ct: ct); + distro, $"{ctx.WslPathPrefix} && openclaw gateway install --force", TimeSpan.FromSeconds(60), ct: ct, inputViaStdin: true); if (result.ExitCode != 0) return StepResult.Fail($"Service install failed (exit {result.ExitCode}): {result.Stderr}"); @@ -30,6 +30,6 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati public override async Task RollbackAsync(SetupContext ctx, CancellationToken ct) { - await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw gateway uninstall", TimeSpan.FromSeconds(30), ct: ct); + await ctx.Commands.RunInWslAsync(ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw gateway uninstall", TimeSpan.FromSeconds(30), ct: ct, inputViaStdin: true); } } diff --git a/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs b/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs index 05493e0aa..813241dad 100644 --- a/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs +++ b/src/OpenClaw.SetupEngine/MintBootstrapTokenStep.cs @@ -30,7 +30,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati }; var mint = await ctx.Commands.RunInWslAsync( - distro, $"{ctx.WslPathPrefix} && openclaw qr --json", TimeSpan.FromSeconds(30), env, ct); + distro, $"{ctx.WslPathPrefix} && openclaw qr --json", TimeSpan.FromSeconds(30), env, ct, inputViaStdin: true); if (mint.ExitCode == 0 && !string.IsNullOrWhiteSpace(mint.Stdout)) { diff --git a/src/OpenClaw.SetupEngine/PairNodeStep.cs b/src/OpenClaw.SetupEngine/PairNodeStep.cs index 4a996da8b..553d684ea 100644 --- a/src/OpenClaw.SetupEngine/PairNodeStep.cs +++ b/src/OpenClaw.SetupEngine/PairNodeStep.cs @@ -292,7 +292,7 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, var pending = await ctx.Commands.RunInWslAsync( distro, $"""{ctx.WslPathPrefix} && openclaw nodes list --json""", - TimeSpan.FromSeconds(30), env, ct); + TimeSpan.FromSeconds(30), env, ct, inputViaStdin: true); ctx.Logger.Info($"Node pending list: exit={pending.ExitCode}"); @@ -323,7 +323,7 @@ internal static async Task AutoApproveNodePairing(SetupContext ctx, var approve = await ctx.Commands.RunInWslAsync( distro, $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(approvalKind)}""", - TimeSpan.FromSeconds(30), approvalEnv, ct); + TimeSpan.FromSeconds(30), approvalEnv, ct, inputViaStdin: true); ctx.Logger.Info($"Node approve result: exit={approve.ExitCode}"); diff --git a/src/OpenClaw.SetupEngine/PairOperatorStep.cs b/src/OpenClaw.SetupEngine/PairOperatorStep.cs index 371b5906d..5f30fa27e 100644 --- a/src/OpenClaw.SetupEngine/PairOperatorStep.cs +++ b/src/OpenClaw.SetupEngine/PairOperatorStep.cs @@ -347,7 +347,7 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri var preview = await ctx.Commands.RunInWslAsync( distro, $"""{ctx.WslPathPrefix} && openclaw devices approve --latest --json""", - TimeSpan.FromSeconds(30), env, ct); + TimeSpan.FromSeconds(30), env, ct, inputViaStdin: true); ctx.Logger.Info($"Approve preview: exit={preview.ExitCode}"); @@ -373,7 +373,7 @@ internal static async Task AutoApprovePairing(SetupContext ctx, stri var approve = await ctx.Commands.RunInWslAsync( distro, $"""{ctx.WslPathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", - TimeSpan.FromSeconds(30), approvalEnv, ct); + TimeSpan.FromSeconds(30), approvalEnv, ct, inputViaStdin: true); ctx.Logger.Info($"Approve result: exit={approve.ExitCode}"); diff --git a/src/OpenClaw.SetupEngine/StartGatewayStep.cs b/src/OpenClaw.SetupEngine/StartGatewayStep.cs index 833fccd6c..56a93a064 100644 --- a/src/OpenClaw.SetupEngine/StartGatewayStep.cs +++ b/src/OpenClaw.SetupEngine/StartGatewayStep.cs @@ -95,7 +95,7 @@ private static async Task StartOrRestartAndWaitForHealthAsync( } var start = await ctx.Commands.RunInWslAsync( - distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct); + distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), ct: ct, inputViaStdin: true); if (start.ExitCode != 0) { @@ -113,7 +113,8 @@ await ctx.Commands.RunInWslAsync( distro, $"{pathCmd} && openclaw gateway {action}", TimeSpan.FromSeconds(30), - ct: ct); + ct: ct, + inputViaStdin: true); if (start.ExitCode != 0) return StepResult.Fail($"Gateway {action} failed after reset: {start.Stderr}"); } diff --git a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs index cf8c6eb99..67044d1ef 100644 --- a/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs +++ b/src/OpenClaw.SetupEngine/TailscaleSetupSteps.cs @@ -566,7 +566,8 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati ctx.DistroName!, $"{ctx.WslPathPrefix} && openclaw config set {ConfigureGatewayStep.DevicePairPublicUrlKey} {WslShellQuoting.QuotePosixSingleQuote(devicePairPublicUrl)} && openclaw config set {ConfigureGatewayStep.DevicePairEnabledKey} true", TimeSpan.FromSeconds(45), - ct: ct); + ct: ct, + inputViaStdin: true); if (configurePairUrl.ExitCode != 0) return StepResult.Fail($"Could not configure the Tailscale device-pair URL: {configurePairUrl.Stderr}"); diff --git a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs index 966a8d794..62ff1eac1 100644 --- a/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs +++ b/src/OpenClaw.SetupEngine/VerifyEndToEndStep.cs @@ -21,7 +21,7 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati // Verify gateway is still healthy var distro = ctx.DistroName!; var status = await ctx.Commands.RunInWslAsync( - distro, $"{ctx.WslPathPrefix} && openclaw gateway status --json", TimeSpan.FromSeconds(15), ct: ct); + distro, $"{ctx.WslPathPrefix} && openclaw gateway status --json", TimeSpan.FromSeconds(15), ct: ct, inputViaStdin: true); if (status.ExitCode != 0 || !status.Stdout.Contains("running", StringComparison.OrdinalIgnoreCase)) return StepResult.Fail("Gateway is not running"); @@ -99,7 +99,7 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon var preview = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && openclaw devices approve --latest --json""", - TimeSpan.FromSeconds(15), env, ct); + TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); if (preview.Stdout.Contains("No pending", StringComparison.OrdinalIgnoreCase) || preview.Stderr.Contains("No pending", StringComparison.OrdinalIgnoreCase)) @@ -115,7 +115,7 @@ internal static async Task DrainPendingDeviceApprovalsAsync(SetupCon var approve = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Device)}""", - TimeSpan.FromSeconds(15), approvalEnv, ct); + TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); if (approve.ExitCode != 0) return StepResult.Fail($"Device approval drain failed for {parsed.RequestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); @@ -165,7 +165,7 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct var nodeList = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && openclaw nodes list --json""", - TimeSpan.FromSeconds(15), env, ct); + TimeSpan.FromSeconds(15), env, ct, inputViaStdin: true); var parsed = ApprovalRequestHelper.TryReadPendingRequestIds(nodeList.Stdout.Trim()); if (!parsed.Success) @@ -186,7 +186,7 @@ private static async Task DrainPendingApprovalsAsync(SetupContext ct var approve = await ctx.Commands.RunInWslAsync( distro, $"""{pathPrefix} && {ApprovalRequestHelper.ApprovalCommand(ApprovalRequestKind.Node)}""", - TimeSpan.FromSeconds(15), approvalEnv, ct); + TimeSpan.FromSeconds(15), approvalEnv, ct, inputViaStdin: true); if (approve.ExitCode != 0) return StepResult.Fail($"Node approval drain failed for {requestId} (exit {approve.ExitCode}): {approve.Stdout.Trim()} {approve.Stderr.Trim()}".Trim()); diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 3a568a07f..5fd2437bc 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5216,6 +5216,52 @@ public void RedactTokens_RedactsEmbeddedTokenOnly() // lives in KeepaliveProcessManager, not StartKeepaliveStep (see setup-keepalive-process-manager // in docs/ARCHITECTURE.md). + [Fact] + public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() + { + var latestApprovals = 0; + var commands = new FakeCommandRunner( + _ => Ok(), + (_, command, _) => + { + if (command.Contains("openclaw qr --json", StringComparison.Ordinal)) + return Ok("""{"bootstrapToken":"boot-token"}"""); + if (command.Contains("devices approve --latest", StringComparison.Ordinal)) + { + latestApprovals++; + return latestApprovals == 1 + ? Ok("""{"selected":{"requestId":"device-req-1"}}""") + : Ok("No pending device approvals"); + } + if (command.Contains("nodes list --json", StringComparison.Ordinal)) + return Ok("""{"pending":[{"requestId":"node-req-1"}]}"""); + if (command.Contains("curl -s", StringComparison.Ordinal)) + return Ok("200"); + return Ok("""{"requestId":"device-req-1"}"""); + }); + var ctx = CreateContext(commands: commands); + ctx.DistroName = "test-distro"; + ctx.SharedGatewayToken = "shared-token"; + + Assert.True((await new InstallGatewayServiceStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess); + await new InstallGatewayServiceStep().RollbackAsync(ctx, CancellationToken.None); + Assert.True((await new MintBootstrapTokenStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess, "mint"); + Assert.True((await PairOperatorStep.AutoApprovePairing(ctx, CancellationToken.None)).IsSuccess, "operator approve"); + Assert.True((await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "node approve"); + Assert.True((await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None)).IsSuccess, "restart"); + Assert.True((await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None)).IsSuccess, "drain"); + + var pathScripts = commands.WslCalls.Where(call => call.Command.Contains("$PATH", StringComparison.Ordinal)).ToList(); + Assert.Contains(pathScripts, call => call.Command.Contains("gateway install --force", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("gateway uninstall", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("openclaw qr --json", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("devices approve", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("nodes list --json", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("nodes approve", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("gateway restart", StringComparison.Ordinal)); + Assert.All(pathScripts, call => Assert.True(call.InputViaStdin, call.Command)); + } + [Fact] public async Task AutoApprovePairing_ReturnsTerminalForDevicePairPluginNotFound() { From 7fa601b4ad9db7c706cc8d46b9bdd0b3827dacf2 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 13:03:45 -0700 Subject: [PATCH 2/2] fix(setup): move remaining WSL PATH scripts to stdin ConfigureGateway and the setup wizard still passed PATH scripts on the wsl.exe argv path. Those calls now use bash -s stdin, with a guard so a new argv PATH script fails the test. Signed-off-by: Sebastien Tardif --- .../ConfigureGatewayStep.cs | 3 +- src/OpenClaw.SetupEngine/SetupWizardRunner.cs | 8 +- .../SetupStepsTests.cs | 10 + .../Architecture/WslPathStdinClosureTests.cs | 483 ++++++++++++++++++ 4 files changed, 501 insertions(+), 3 deletions(-) create mode 100644 tests/OpenClaw.Shared.Tests/Architecture/WslPathStdinClosureTests.cs diff --git a/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs b/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs index f09ac6215..ae72546eb 100644 --- a/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs +++ b/src/OpenClaw.SetupEngine/ConfigureGatewayStep.cs @@ -89,7 +89,8 @@ public override async Task ExecuteAsync(SetupContext ctx, Cancellati """; var timeout = ComputeConfigurationTimeout(configCommands); - var result = await ctx.Commands.RunInWslAsync(distro, script, timeout, env, ct); + // PATH prefix references $PATH. Pipe the script so wsl.exe cannot expand it on argv. + var result = await ctx.Commands.RunInWslAsync(distro, script, timeout, env, ct, inputViaStdin: true); if (result.ExitCode != 0 || !result.Stdout.Contains("GATEWAY_CONFIGURED")) { diff --git a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs index 6c70ec18e..55430a4f3 100644 --- a/src/OpenClaw.SetupEngine/SetupWizardRunner.cs +++ b/src/OpenClaw.SetupEngine/SetupWizardRunner.cs @@ -106,12 +106,14 @@ internal async Task SuspendReloadModeAsync() { try { + // PATH prefix references $PATH. Pipe the script so wsl.exe cannot expand it on argv. var result = await _ctx.Commands.RunInWslAsync( _ctx.DistroName!, $"{_ctx.WslPathPrefix} && openclaw config set gateway.reload.mode off", TimeSpan.FromSeconds(15), // This bounded handoff must finish so we know whether restoration is required. - ct: CancellationToken.None); + ct: CancellationToken.None, + inputViaStdin: true); if (result.ExitCode != 0) { return StepResult.Fail( @@ -653,11 +655,13 @@ private async Task RunReloadModeRestorationCommandAsync(string re if (lastResult is not null && remaining < MinimumReloadRestorationCommandTimeout) return lastResult; + // PATH prefix references $PATH. Pipe the script so wsl.exe cannot expand it on argv. var result = await _ctx.Commands.RunInWslAsync( _ctx.DistroName!, command, remaining, - ct: CancellationToken.None); + ct: CancellationToken.None, + inputViaStdin: true); lastResult = result; if (result.ExitCode == 0 || !IsStartupMigrationLeaseContention(result)) diff --git a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs index 5fd2437bc..85738b2f3 100644 --- a/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs +++ b/tests/OpenClaw.SetupEngine.Tests/SetupStepsTests.cs @@ -5235,6 +5235,8 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() } if (command.Contains("nodes list --json", StringComparison.Ordinal)) return Ok("""{"pending":[{"requestId":"node-req-1"}]}"""); + if (command.Contains("GATEWAY_CONFIGURED", StringComparison.Ordinal)) + return Ok("GATEWAY_CONFIGURED"); if (command.Contains("curl -s", StringComparison.Ordinal)) return Ok("200"); return Ok("""{"requestId":"device-req-1"}"""); @@ -5242,6 +5244,7 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() var ctx = CreateContext(commands: commands); ctx.DistroName = "test-distro"; ctx.SharedGatewayToken = "shared-token"; + ctx.Config.Gateway.ReloadMode = "hybrid"; Assert.True((await new InstallGatewayServiceStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess); await new InstallGatewayServiceStep().RollbackAsync(ctx, CancellationToken.None); @@ -5250,6 +5253,10 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() Assert.True((await PairNodeStep.AutoApproveNodePairing(ctx, requestId: null, CancellationToken.None)).IsSuccess, "node approve"); Assert.True((await StartGatewayStep.RestartAndWaitForHealthAsync(ctx, CancellationToken.None)).IsSuccess, "restart"); Assert.True((await VerifyEndToEndStep.DrainPendingDeviceApprovalsAsync(ctx, CancellationToken.None)).IsSuccess, "drain"); + Assert.True((await new ConfigureGatewayStep().ExecuteAsync(ctx, CancellationToken.None)).IsSuccess, "configure"); + TrustManagedEndpoint(ctx); + Assert.True((await new SetupWizardRunner(ctx).SuspendReloadModeAsync()).IsSuccess, "suspend reload"); + Assert.True((await new SetupWizardRunner(ctx).RestoreReloadModeAsync()).IsSuccess, "restore reload"); var pathScripts = commands.WslCalls.Where(call => call.Command.Contains("$PATH", StringComparison.Ordinal)).ToList(); Assert.Contains(pathScripts, call => call.Command.Contains("gateway install --force", StringComparison.Ordinal)); @@ -5259,6 +5266,9 @@ public async Task WslPathPrefixScripts_UseStdinSoWslExeDoesNotExpandPath() Assert.Contains(pathScripts, call => call.Command.Contains("nodes list --json", StringComparison.Ordinal)); Assert.Contains(pathScripts, call => call.Command.Contains("nodes approve", StringComparison.Ordinal)); Assert.Contains(pathScripts, call => call.Command.Contains("gateway restart", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("GATEWAY_CONFIGURED", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("gateway.reload.mode off", StringComparison.Ordinal)); + Assert.Contains(pathScripts, call => call.Command.Contains("gateway.reload.mode 'hybrid'", StringComparison.Ordinal)); Assert.All(pathScripts, call => Assert.True(call.InputViaStdin, call.Command)); } diff --git a/tests/OpenClaw.Shared.Tests/Architecture/WslPathStdinClosureTests.cs b/tests/OpenClaw.Shared.Tests/Architecture/WslPathStdinClosureTests.cs new file mode 100644 index 000000000..30880811e --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/Architecture/WslPathStdinClosureTests.cs @@ -0,0 +1,483 @@ +using System.Text.RegularExpressions; +using Xunit; + +namespace OpenClaw.Shared.Tests.Architecture; + +/// +/// wsl.exe expands $PATH in argv before bash starts, so a PATH prefix passed to +/// bash -c becomes the Windows PATH. RunInWslAsync(..., inputViaStdin: true) +/// pipes the script to bash -s instead. This scan fails when a new production +/// call passes a PATH-bearing script on the argv path. +/// +public sealed class WslPathStdinClosureTests +{ + private static readonly Regex PathMarker = new( + @"\$\{?PATH\b|WslPathPrefix|OpenClawWslPathPrefix|GetPathPrefix|\.PathPrefix\b", + RegexOptions.CultureInvariant); + + private static readonly Regex Identifier = new( + @"\b[A-Za-z_][A-Za-z0-9_]*\b", + RegexOptions.CultureInvariant); + + private static readonly HashSet IgnoredIdentifiers = new(StringComparer.Ordinal) + { + "var", "string", "true", "false", "null", "new", "await", "return", "if", "else", + "set", "get", "echo", "openclaw", "config", "gateway", "and", "or", "not", + "Task", "CommandResult", "TimeSpan", "CancellationToken", "FromSeconds", + }; + + [Fact] + public void PathBearingRunInWslScripts_UseStdinNotArgv() + { + var failures = new List(); + foreach (var file in ProductionSourceFiles.All) + { + foreach (var call in FindInvocations(file.Text, "RunInWslAsync")) + { + if (IsMethodDeclaration(file.Text, call.Index)) + continue; + if (HasNamedTrue(call.Args, "inputViaStdin")) + continue; + if (!CommandIsPathBearing(file.Text, call.Index, call.Args)) + continue; + + var line = LineOf(file.Text, call.Index); + failures.Add($"{RelativePath(file.Path)}:{line}"); + } + } + + Assert.True( + failures.Count == 0, + "PATH-bearing WSL scripts must use RunInWslAsync(..., inputViaStdin: true) " + + "so wsl.exe does not expand $PATH on argv. See docs/WSL_EXE_ARGV_PITFALL.md. " + + string.Join(", ", failures)); + } + + private static bool CommandIsPathBearing(string text, int callIndex, string args) + { + var command = ExtractCommandArgument(args); + return command is not null && ExpressionIsPathBearing(text, callIndex, command, depth: 0); + } + + private static bool ExpressionIsPathBearing(string text, int beforeIndex, string expression, int depth) + { + if (PathMarker.IsMatch(expression)) + return true; + if (depth >= 6) + return false; + + foreach (Match match in Identifier.Matches(expression)) + { + var name = match.Value; + if (IgnoredIdentifiers.Contains(name) || PathMarker.IsMatch(name)) + continue; + + var assigned = NearestAssignment(text, beforeIndex, name); + if (assigned is null) + continue; + if (ExpressionIsPathBearing(text, assigned.Value.At, assigned.Value.Expr, depth + 1)) + return true; + } + + return false; + } + + private static (int At, string Expr)? NearestAssignment(string text, int beforeIndex, string name) + { + var pattern = new Regex(@"\b" + Regex.Escape(name) + @"\s*=(?!=)", RegexOptions.CultureInvariant); + (int At, string Expr)? nearest = null; + foreach (Match match in pattern.Matches(text)) + { + if (match.Index >= beforeIndex) + break; + if (match.Index > 0 && "=!<>".Contains(text[match.Index - 1])) + continue; + + var exprStart = match.Index + match.Length; + var expr = ReadExpression(text, exprStart); + if (expr is null) + continue; + nearest = (exprStart, expr); + } + + return nearest; + } + + private static string? ExtractCommandArgument(string args) + { + var parts = SplitTopLevel(args, ','); + foreach (var part in parts) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith("command:", StringComparison.Ordinal)) + return trimmed["command:".Length..]; + } + + return parts.Count > 1 ? parts[1] : null; + } + + private static bool HasNamedTrue(string args, string name) + { + foreach (var part in SplitTopLevel(args, ',')) + { + var trimmed = part.Trim(); + if (trimmed.StartsWith(name + ":", StringComparison.Ordinal) && + trimmed.EndsWith("true", StringComparison.Ordinal) && + Regex.IsMatch(trimmed, @"^" + Regex.Escape(name) + @"\s*:\s*true$")) + { + return true; + } + } + + return false; + } + + private static bool IsMethodDeclaration(string text, int index) + { + var start = Math.Max(0, index - 80); + var prefix = text[start..index]; + return prefix.Contains("Task", StringComparison.Ordinal) || + prefix.Contains("Task", StringComparison.Ordinal); + } + + private readonly record struct Invocation(int Index, string Args); + + private static List FindInvocations(string text, string name) + { + var results = new List(); + var i = 0; + while (i < text.Length) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + + if (IsIdentifierAt(text, i, name)) + { + var after = i + name.Length; + var open = IndexOfOpenParen(text, after); + if (open >= 0) + { + var close = FindMatchingParen(text, open); + if (close > open) + { + results.Add(new Invocation(i, text[(open + 1)..close])); + i = close + 1; + continue; + } + } + } + + i++; + } + + return results; + } + + private static int IndexOfOpenParen(string text, int start) + { + for (var i = start; i < text.Length; i++) + { + if (char.IsWhiteSpace(text[i])) + continue; + return text[i] == '(' ? i : -1; + } + + return -1; + } + + private static int FindMatchingParen(string text, int open) + { + var depth = 0; + var i = open; + while (i < text.Length) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + if (text[i] == '(') + depth++; + else if (text[i] == ')') + { + depth--; + if (depth == 0) + return i; + } + + i++; + } + + return -1; + } + + private static List SplitTopLevel(string text, char separator) + { + var parts = new List(); + var start = 0; + var depth = 0; + var i = 0; + while (i < text.Length) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + var ch = text[i]; + if (ch is '(' or '[' or '{') + depth++; + else if (ch is ')' or ']' or '}') + depth--; + else if (ch == separator && depth == 0) + { + parts.Add(text[start..i]); + start = i + 1; + } + + i++; + } + + parts.Add(text[start..]); + return parts; + } + + private static string? ReadExpression(string text, int start) + { + var depth = 0; + var i = start; + while (i < text.Length) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + var ch = text[i]; + if (ch is '(' or '[' or '{') + depth++; + else if (ch is ')' or ']' or '}') + depth--; + else if (ch == ';' && depth == 0) + return text[start..i]; + + i++; + } + + return null; + } + + private static bool TryConsumeStringOrComment(string text, ref int i) + { + if (i >= text.Length) + return false; + + if (i + 1 < text.Length && text[i] == '/' && text[i + 1] == '/') + { + i += 2; + while (i < text.Length && text[i] != '\n') + i++; + return true; + } + + if (i + 1 < text.Length && text[i] == '/' && text[i + 1] == '*') + { + i += 2; + while (i + 1 < text.Length && !(text[i] == '*' && text[i + 1] == '/')) + i++; + i = Math.Min(text.Length, i + 2); + return true; + } + + if (text[i] == '\'') + { + i++; + if (i < text.Length && text[i] == '\\') + i++; + if (i < text.Length) + i++; + if (i < text.Length && text[i] == '\'') + i++; + return true; + } + + var dollarCount = 0; + var cursor = i; + while (cursor < text.Length && text[cursor] == '$') + { + dollarCount++; + cursor++; + } + + var verbatim = cursor < text.Length && text[cursor] == '@'; + if (verbatim) + cursor++; + + if (cursor >= text.Length || text[cursor] != '"') + return false; + if (dollarCount == 0 && !verbatim && cursor != i) + return false; + + var quoteStart = cursor; + var rawQuotes = CountRawQuotes(text, quoteStart); + if (rawQuotes >= 3) + { + i = SkipRawString(text, quoteStart + rawQuotes, rawQuotes, dollarCount); + return true; + } + + if (dollarCount > 1 && !verbatim) + return false; + + i = SkipQuotedString(text, quoteStart + 1, verbatim, dollarCount > 0); + return true; + } + + private static int CountRawQuotes(string text, int start) + { + var count = 0; + while (start + count < text.Length && text[start + count] == '"') + count++; + return count; + } + + private static int SkipRawString(string text, int contentStart, int quoteCount, int dollarCount) + { + var i = contentStart; + while (i < text.Length) + { + if (dollarCount > 0 && text[i] == '{') + { + var braces = CountChar(text, i, '{'); + if (braces > dollarCount) + { + i += braces; + continue; + } + + if (braces == dollarCount) + { + i += braces; + var depth = 1; + while (i < text.Length && depth > 0) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + if (text[i] == '{') + depth++; + else if (text[i] == '}') + depth--; + if (depth > 0) + i++; + } + + if (i < text.Length && text[i] == '}') + i++; + continue; + } + } + + if (text[i] == '"') + { + var run = CountRawQuotes(text, i); + if (run >= quoteCount) + return i + quoteCount; + } + + i++; + } + + return text.Length; + } + + private static int SkipQuotedString(string text, int start, bool verbatim, bool interpolated) + { + var i = start; + while (i < text.Length) + { + if (verbatim && text[i] == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') + { + i += 2; + continue; + } + + return i + 1; + } + + if (!verbatim && text[i] == '\\') + { + i += Math.Min(2, text.Length - i); + continue; + } + + if (interpolated && text[i] == '{') + { + if (i + 1 < text.Length && text[i + 1] == '{') + { + i += 2; + continue; + } + + i++; + var depth = 1; + while (i < text.Length && depth > 0) + { + if (TryConsumeStringOrComment(text, ref i)) + continue; + if (text[i] == '{') + depth++; + else if (text[i] == '}') + depth--; + if (depth > 0) + i++; + } + + if (i < text.Length && text[i] == '}') + i++; + continue; + } + + if (text[i] == '"') + return i + 1; + + i++; + } + + return text.Length; + } + + private static int CountChar(string text, int start, char value) + { + var count = 0; + while (start + count < text.Length && text[start + count] == value) + count++; + return count; + } + + private static bool IsIdentifierAt(string text, int index, string name) + { + if (index < 0 || index + name.Length > text.Length) + return false; + if (!text.AsSpan(index, name.Length).SequenceEqual(name)) + return false; + if (index > 0 && IsIdentifierChar(text[index - 1])) + return false; + var end = index + name.Length; + return end >= text.Length || !IsIdentifierChar(text[end]); + } + + private static bool IsIdentifierChar(char value) => + char.IsLetterOrDigit(value) || value == '_'; + + private static int LineOf(string text, int index) + { + var line = 1; + for (var i = 0; i < index && i < text.Length; i++) + { + if (text[i] == '\n') + line++; + } + + return line; + } + + private static string RelativePath(string path) + { + var marker = $"{Path.DirectorySeparatorChar}src{Path.DirectorySeparatorChar}"; + var at = path.LastIndexOf(marker, StringComparison.Ordinal); + return at >= 0 ? path[(at + 1)..].Replace(Path.DirectorySeparatorChar, '/') : path; + } +}