Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Start with these docs before changing connection, pairing, node, MCP, or tray UX
- `docs/MCP_MODE.md` - local MCP server mode and the `EnableNodeMode` / `EnableMcpServer` matrix.
- `docs/WINDOWS_NODE_TESTING.md` - Windows node capabilities, manual smokes, and gateway-dependent behavior.
- `docs/ONBOARDING_WIZARD.md` - first-run setup flow, setup-code/bootstrap pairing, and test isolation.
- `docs/WSL_EXE_ARGV_PITFALL.md` - wsl.exe argv variable-expansion pitfall; required reading before adding any multi-line WSL script through `RunInWslAsync`.

Important current facts:

Expand Down
2 changes: 2 additions & 0 deletions docs/ONBOARDING_WIZARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Installs and connects a new app-owned `OpenClawGateway` WSL instance from a clea

The managed distro is locked down and is not intended to be a normal interactive Ubuntu profile. For editing `openclaw.json` as the `openclaw` user and using root for protected-file administration, see [Managing the locked-down WSL gateway](WSL_GATEWAY_ADMIN.md).

After node pairing, local WSL setup ensures OpenClaw has seeded the runtime workspace, then writes fixed Windows-node guidance into a setup-owned managed section of that workspace's `AGENTS.md`. The section is replaced idempotently between markers, preserves user-authored `AGENTS.md` content outside those markers, and does not modify OpenClaw source files. This helps the initial companion-app OpenClaw session know to use the Windows node / `nodes` tool for Windows desktop, files, screenshots, camera, notifications, browser proxy, and Windows command tasks.

### Wizard
Renders server-defined setup steps via RPC (`wizard.start` / `wizard.next`). The gateway controls the flow — steps can be:
- **Note** — informational messages
Expand Down
8 changes: 8 additions & 0 deletions docs/WINDOWS_NODE_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ The Windows Node feature allows the tray app to receive commands from the OpenCl
4. Toggle "Enable Node Mode" ON
5. Click Save

## Companion-App Setup Guidance

For app-owned local WSL setup, after node pairing, setup ensures OpenClaw has seeded the runtime workspace and then injects fixed Windows-node guidance into that workspace's `AGENTS.md`. The injected block is setup-owned and idempotently replaced between managed markers, preserving any user-authored `AGENTS.md` content outside those markers and leaving OpenClaw source files unchanged.

**Note on the apply script's WSL invocation.** The `WindowsNodeBootstrapContextStep` apply and rollback scripts are piped to `bash -s` via stdin (`RunInWslAsync(..., inputViaStdin: true)`) rather than the default `bash -c` argv path. This is required because `wsl.exe` performs shell variable expansion on argv before invoking bash, which would drop user-defined `$var` references in the multi-line script (`workspace='...'` followed by `mkdir -p "$workspace"` becomes `mkdir -p ""`). See `docs/WSL_EXE_ARGV_PITFALL.md` for the full writeup.

The guidance helps the first companion-app OpenClaw session route Windows desktop, files, screenshots, camera, notifications, browser proxy, and Windows command tasks through the Windows node / `nodes` tool.

## What You Can Test Now

### 1. Settings Toggle
Expand Down
134 changes: 134 additions & 0 deletions docs/WSL_EXE_ARGV_PITFALL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# WSL.exe argv variable-expansion pitfall

## Summary

`wsl.exe -- bash -c <script>` expands shell-variable references in argv before invoking `bash`, so Bash receives an already-mutated script string; any `$var` or `${var}` not defined in the Windows process environment at `wsl.exe` invocation time is dropped to an empty string.

## Reproduction

```powershell
function Invoke-Wsl([string[]]$arr) {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "wsl.exe"
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
foreach ($a in $arr) { [void]$psi.ArgumentList.Add($a) }
$p = [System.Diagnostics.Process]::Start($psi)
$out = $p.StandardOutput.ReadToEnd()
$err = $p.StandardError.ReadToEnd()
$p.WaitForExit()
"EXIT=$($p.ExitCode) STDOUT=[$out] STDERR=[$err]"
}

# BROKEN: argv path — $x is dropped before bash sees it
Invoke-Wsl @("-d","Ubuntu-26.04","--","bash","-c","x=abc; echo VAL=`$x")
# → EXIT=0 STDOUT=[VAL=] ← assignment ran, but $x was already expanded to empty

# WORKING: stdin path — script bytes arrive at bash intact
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "wsl.exe"
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardInput = $true
$psi.UseShellExecute = $false
foreach ($a in @("-d","Ubuntu-26.04","--","bash","-s")) { [void]$psi.ArgumentList.Add($a) }
$p = [System.Diagnostics.Process]::Start($psi)
$p.StandardInput.WriteLine("x=abc; echo VAL=`$x")
$p.StandardInput.Close()
$p.StandardOutput.ReadToEnd()
# → VAL=abc
```

## What bash actually receives

Empirical results from dumping `/proc/$$/cmdline` inside Bash on a fresh Ubuntu-26.04 WSL distro:

| Pattern in script string | What bash actually receives |
|---|---|
| `$PATH` (defined in Windows env at `wsl.exe` invocation time) | the full Windows `PATH` expansion |
| `$workspace` (not defined in Windows env) | **removed (empty)** |
| `${workspace}` braced (not defined in Windows env) | **removed (empty)** |
| `$$` | `wsl.exe` parent process's PID, not the Bash PID |
| `\$workspace` backslash-escaped | preserved as `$workspace`; Bash then expands it normally |
| `$(echo hi)` command substitution | preserved; Bash expands it |
| Single-quoted `'$workspace'` | still expanded; single quotes do not help because `wsl.exe` runs before Bash |
| Double-quoted `"$workspace"` | still expanded |
| Subshell `( workspace=x; echo $workspace )` | the inner `$workspace` is still dropped |
| Prefix assignment `x=abc echo $x` | `$x` is still dropped |

Concrete failure mode:

```bash
workspace='/home/openclaw/.openclaw/workspace'
mkdir -p "$workspace" # → mkdir: cannot create directory '': No such file or directory
```

The assignment runs because it has no `$var` reference, but `$workspace` on the next line is removed during `wsl.exe` argv translation.

## Why

`wsl.exe`'s argv translation layer treats argv strings as command-line text and performs shell metacharacter expansion before launching the target process. By the time `bash -c` runs, the original `$var` syntax is gone and Bash cannot recover it. This behavior is consistent across single quotes, double quotes, braces, subshells, and prefix assignment because they are all interpreted by `wsl.exe` before Bash sees the script.

## Fixes (in order of preference)

### 1. Pipe the script over stdin via `RunInWslAsync(..., inputViaStdin: true)`

`wsl.exe` does not rewrite stdin. Prefer this for any multi-line script that uses Bash variables, `${...}`, or `$$`.

```csharp
var script = """
workspace='/home/openclaw/.openclaw/workspace'
mkdir -p "$workspace"
printf 'bash pid=%s\n' "$$"
""";

await commandRunner.RunInWslAsync(
distroName,
script,
cancellationToken,
inputViaStdin: true);
```

### 2. C#-interpolate every value into the script string

Do not store values in Bash variables; bake the values into the script literally. This is the workaround used by `src/OpenClaw.SetupEngine/SetupSteps.cs:936-945` in `ValidateWslLockdownStep`. It is acceptable for short scripts with a small fixed value set and no spaces in values.

```csharp
var workspace = "/home/openclaw/.openclaw/workspace";
var script = $"mkdir -p {workspace} && test -d {workspace}";

await commandRunner.RunInWslAsync(distroName, script, cancellationToken);
```

### 3. Backslash-escape `\$var`

Escaping the dollar sign preserves it through `wsl.exe` and lets Bash expand it later.

```powershell
wsl.exe -d Ubuntu-26.04 -- bash -c "x=abc; echo VAL=\`$x"
```

This works for single isolated references, but it is fragile and easy to miss in any non-trivial script. Treat it as a last resort.

## What does NOT work

All of these failed workarounds were verified empirically:

- **Single quotes** — `'$workspace'` is still rewritten before Bash sees the quotes.
- **Double quotes** — `"$workspace"` is also rewritten before Bash sees the quotes.
- **Braces** — `${workspace}` is removed just like `$workspace`.
- **Subshells** — `( workspace=x; echo $workspace )` still loses the inner `$workspace`.
- **Prefix assignment** — `x=abc echo $x` still expands `$x` before the assignment can matter.
- **`-e VAR=val` flag forwarding** — forwarded environment values do not prevent argv rewriting before Bash receives the script.
- **Switching to `/bin/sh`** — the mutation happens in `wsl.exe`, before any shell starts.

## Where this matters in the codebase

- `src/OpenClaw.SetupEngine/CommandRunner.cs` — `RunInWslAsync` exposes the opt-in `inputViaStdin` parameter.
- `src/OpenClaw.SetupEngine/SetupSteps.cs:936-945` — `ValidateWslLockdownStep` uses workaround #2, C# interpolation.
- `src/OpenClaw.SetupEngine/SetupSteps.cs` `WindowsNodeBootstrapContextStep` — uses workaround #1, stdin.

## Related

- `docs/XAML_COMPILER_BUG.md` — sibling footgun doc for XAML compiler crashes.
87 changes: 84 additions & 3 deletions src/OpenClaw.SetupEngine/CommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,52 @@ Task<CommandResult> RunAsync(
string? stdinInput = null,
CancellationToken ct = default);

/// <summary>
/// Run a command inside a WSL distro.
/// </summary>
/// <remarks>
/// <para>
/// By default (<paramref name="inputViaStdin"/> = false) the script is passed
/// as argv to <c>wsl.exe -- bash -c &lt;script&gt;</c>. wsl.exe performs shell
/// variable expansion on argv before invoking bash, which drops any
/// <c>$var</c> or <c>${var}</c> reference that is not defined in the Windows
/// process environment. See <c>docs/WSL_EXE_ARGV_PITFALL.md</c> for the full
/// writeup.
/// </para>
/// <para>
/// For multi-line scripts that use bash variables, set
/// <paramref name="inputViaStdin"/> to <c>true</c>. The script is then piped
/// to <c>bash -s</c> via stdin, which wsl.exe does not touch.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Safe: bash variables survive because the script arrives via stdin.
/// await runner.RunInWslAsync(distro, """
/// workspace='/home/me/ws'
/// mkdir -p "$workspace"
/// """, TimeSpan.FromSeconds(15), inputViaStdin: true);
/// </code>
/// </example>
/// <param name="distroName">The WSL distribution name passed to <c>wsl.exe -d</c>.</param>
/// <param name="command">The bash script or command to run.</param>
/// <param name="timeout">The maximum time to wait before killing the process.</param>
/// <param name="environment">Optional environment variables to forward into WSL via WSLENV.</param>
/// <param name="ct">A cancellation token for aborting the process.</param>
/// <param name="user">Optional WSL user passed to <c>wsl.exe -u</c>.</param>
/// <param name="inputViaStdin">
/// When true, the script is piped to <c>bash -s</c> via stdin instead of
/// being passed as argv to <c>bash -c</c>. Use this for any multi-line
/// script that uses bash variables or <c>$$</c>.
/// </param>
Task<CommandResult> RunInWslAsync(
string distroName,
string command,
TimeSpan timeout,
IReadOnlyDictionary<string, string>? environment = null,
CancellationToken ct = default,
string? user = null);
string? user = null,
bool inputViaStdin = false);
}

public sealed class CommandRunner : ICommandRunner
Expand Down Expand Up @@ -139,13 +178,49 @@ public async Task<CommandResult> RunAsync(
/// <summary>
/// Run a command inside a WSL distro.
/// </summary>
/// <remarks>
/// <para>
/// By default (<paramref name="inputViaStdin"/> = false) the script is passed
/// as argv to <c>wsl.exe -- bash -c &lt;script&gt;</c>. wsl.exe performs shell
/// variable expansion on argv before invoking bash, which drops any
/// <c>$var</c> or <c>${var}</c> reference that is not defined in the Windows
/// process environment. See <c>docs/WSL_EXE_ARGV_PITFALL.md</c> for the full
/// writeup.
/// </para>
/// <para>
/// For multi-line scripts that use bash variables, set
/// <paramref name="inputViaStdin"/> to <c>true</c>. The script is then piped
/// to <c>bash -s</c> via stdin, which wsl.exe does not touch.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // Safe: bash variables survive because the script arrives via stdin.
/// await runner.RunInWslAsync(distro, """
/// workspace='/home/me/ws'
/// mkdir -p "$workspace"
/// """, TimeSpan.FromSeconds(15), inputViaStdin: true);
/// </code>
/// </example>
/// <param name="distroName">The WSL distribution name passed to <c>wsl.exe -d</c>.</param>
/// <param name="command">The bash script or command to run.</param>
/// <param name="timeout">The maximum time to wait before killing the process.</param>
/// <param name="environment">Optional environment variables to forward into WSL via WSLENV.</param>
/// <param name="ct">A cancellation token for aborting the process.</param>
/// <param name="user">Optional WSL user passed to <c>wsl.exe -u</c>.</param>
/// <param name="inputViaStdin">
/// When true, the script is piped to <c>bash -s</c> via stdin instead of
/// being passed as argv to <c>bash -c</c>. Use this for any multi-line
/// script that uses bash variables or <c>$$</c>.
/// </param>
public Task<CommandResult> RunInWslAsync(
string distroName,
string command,
TimeSpan timeout,
IReadOnlyDictionary<string, string>? environment = null,
CancellationToken ct = default,
string? user = null)
string? user = null,
bool inputViaStdin = false)
{
// Strip Windows \r to avoid bash "$'\r': command not found" errors
command = command.Replace("\r", "");
Expand All @@ -158,7 +233,10 @@ public Task<CommandResult> RunInWslAsync(
args.Add(user);
}

args.AddRange(["--", "bash", "-c", command]);
if (inputViaStdin)
args.AddRange(["--", "bash", "-s"]);
else
args.AddRange(["--", "bash", "-c", command]);

// Pass WSL environment variables via WSLENV
Dictionary<string, string>? env = null;
Expand All @@ -171,6 +249,9 @@ public Task<CommandResult> RunInWslAsync(
: wslEnvKeys;
}

if (inputViaStdin)
return RunAsync("wsl.exe", args.ToArray(), timeout, env, stdinInput: command, ct: ct);

return RunAsync("wsl.exe", args.ToArray(), timeout, env, ct: ct);
}

Expand Down
10 changes: 10 additions & 0 deletions src/OpenClaw.SetupEngine/SetupContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public sealed class SetupConfig
public CapabilitiesConfig Capabilities { get; set; } = new();
public TraySettingsConfig Settings { get; set; } = new();
public PairingConfig Pairing { get; set; } = new();
public WindowsNodeContextConfig WindowsNodeContext { get; set; } = new();

public string EffectiveGatewayUrl => GatewayUrl ?? $"ws://localhost:{GatewayPort}";

Expand Down Expand Up @@ -234,6 +235,15 @@ public sealed class PairingConfig
public int TimeoutSeconds { get; set; } = 60;
}

// ─── Windows Node Context Injection ───

public sealed class WindowsNodeContextConfig
{
public bool Enabled { get; set; } = true;
public string? WorkspacePath { get; set; }
public int TimeoutSeconds { get; set; } = 180;
}

// ─── Step Result ───

public enum StepOutcome { Success, Skipped, Failed, FailedTerminal }
Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.SetupEngine/SetupPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public static List<SetupStep> BuildDefaultSteps()
new MintBootstrapTokenStep(),
new PairOperatorStep(),
new PairNodeStep(),
new WindowsNodeBootstrapContextStep(),
new VerifyEndToEndStep(),
new RunGatewayWizardStep(),
new StartKeepaliveStep(),
Expand Down
Loading
Loading