openclaw-windows-node/tests/OpenClaw.WinNode.Cli.Tests/RunAsyncTests.cs
Chris Anderson 3b8793db37
feat: winnode CLI for invoking node commands over local MCP (#250)
* feat: winnode CLI for invoking node commands over local MCP

Mirrors `openclaw nodes invoke`'s flag surface but routes to the local
tray's MCP HTTP server (default http://127.0.0.1:8765/) instead of the
gateway. `--node` and `--idempotency-key` are accepted for paste-from-
gateway parity and ignored.

Ships skill.md alongside winnode.exe documenting every supported
command, argument schema, and the A2UI v0.8 JSONL grammar for agent use.

Tests: 62 cases, 100% line/branch on CliRunner via in-process unit tests
plus a loopback HttpListener fake that exercises the full HTTP path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): gate MCP readiness on token-bearing client

InitializeAsync would return ready as soon as `GET /` returned 200, even
if `mcp-token.txt` had not been read yet. Against a tray binary built
before the auth-before-dispatch hardening (where `GET /` answers 200
without auth), this raced ahead and handed back a tokenless `Client` —
every subsequent POST then 401'd. Restructure the loop to require both
the token-on-disk and a 200 from a token-bearing GET before declaring
ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(winnode): auto-load MCP bearer token

The CLI now sends `Authorization: Bearer <token>` on every MCP request,
without the user having to plumb the token themselves. Resolution chain
mirrors the per-tool secret convention (gh, az, anthropic):

  1. `--mcp-token <literal>` flag
  2. `OPENCLAW_MCP_TOKEN` env var (literal)
  3. `mcp-token.txt` under `$OPENCLAW_TRAY_DATA_DIR` if set, else
     `%APPDATA%\OpenClawTray\` — the same location SettingsManager
     points the tray at, so a sandboxed tray is found automatically.

When the token comes from disk, run `McpAuthToken.VerifyAcl` (the same
hygiene check `NodeService.StartMcpServer` runs at startup) and route
any owner/DACL warning to stderr so the user knows to rotate. `--verbose`
reports the resolved auth source without echoing the secret value.

Tests redirect via `OPENCLAW_TRAY_DATA_DIR` to a temp sandbox dir so they
don't pick up the developer machine's real tray token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(winnode): apply 19 review findings (F-01..F-21)

Hardens the winnode CLI against the threat model in
C:/temp/winnode-cli-review-2026-04-30/01-findings.md. F-15 (port-0 nit)
was approved as no-action; F-17 was a positive observation.

- F-01/F-09: validate --mcp-url; refuse auto-loaded token off-loopback
- F-02: explicit SocketsHttpHandler with AllowAutoRedirect=false
- F-03: cap response body at 16 MiB with explicit overflow message
- F-04: warn unconditionally when --mcp-token is used (process-listing leak)
- F-05: warn unconditionally when --idempotency-key is supplied
- F-06: TokenLooksValid ASCII-printable check; ignore corrupt tokens
- F-07: don't echo full token-file path in --verbose
- F-08: canonicalize OPENCLAW_TRAY_DATA_DIR; reject symlink redirect
- F-10: RunAsyncTests is now IDisposable (cleans up sandbox dir)
- F-11: SkillMdDriftTests + REGENERATE-ME header in skill.md;
        McpToolBridge.KnownCommands exposes the canonical command set;
        skill.md re-synced with live capability surface
- F-12: --params @<path> loads JSON object from disk
- F-13: Token_file_with_wide_acl_emits_warn (Windows-only, gracefully
        skips when SetAccessControl is denied by hardened CI)
- F-14: BuildToolsCallBody returns (byte[], int) consumed by
        ByteArrayContent without a string round-trip
- F-16+F-21: SanitizeForStderr strips control chars, redacts ≥32-char
        base64url runs, caps at 4 KiB, default-quiet first-line-only,
        full sanitized body under --verbose
- F-18: --invoke-timeout capped at 600000 ms; long arithmetic on the
        +5000 buffer; out-of-range exits 2
- F-19: --mcp-port and OPENCLAW_MCP_PORT bounded [1, 65535]; env-var
        out-of-range falls back to default with a verbose warning
- F-20: distinguish missing/empty/unreadable/loaded token-file states;
        unreadable exits 1 with a diagnostic before any HTTP traffic

Tests: 23 added (115/115 pass). All other suites stay green
(Shared 1046/1066, Tray 245/245, Integration 18/18, UI 62/62).
WinNode CLI line coverage: 91.6% (434/474 in Program.cs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:27:50 -07:00

580 lines
22 KiB
C#

using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using OpenClaw.WinNode.Cli;
namespace OpenClaw.WinNode.Cli.Tests;
public class RunAsyncTests : IDisposable
{
// Tests run on developer machines where %APPDATA%\OpenClawTray\mcp-token.txt
// may exist with the live tray's token. Without an override, the CLI's
// automatic loader would happily pick that up and set an Authorization
// header for every test request, which is hermeticity-poison even if the
// FakeMcpServer ignores it. Redirect via OPENCLAW_TRAY_DATA_DIR (same
// sandbox env var the tray and integration tests honor) at a guaranteed-
// empty temp directory so the loader finds no file and runs without auth.
//
// F-10: per-instance + IDisposable so each test cleans up after itself.
// The directory must exist (F-08's path-canonicalization step needs to
// resolve a real directory) so we create it eagerly here.
private readonly string _sandboxDataDir;
public RunAsyncTests()
{
_sandboxDataDir = Path.Combine(Path.GetTempPath(), $"winnode-test-sandbox-{Guid.NewGuid():N}");
Directory.CreateDirectory(_sandboxDataDir);
}
public void Dispose()
{
try { Directory.Delete(_sandboxDataDir, recursive: true); } catch { /* best effort */ }
}
private Func<string, string?> EmptyEnv => key =>
key == "OPENCLAW_TRAY_DATA_DIR" ? _sandboxDataDir : null;
private static (StringWriter Out, StringWriter Err) Buffers()
=> (new StringWriter(), new StringWriter());
[Fact]
public async Task No_args_prints_usage_and_exits_2()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(Array.Empty<string>(), o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("winnode", o.ToString());
Assert.Equal("", e.ToString());
}
[Fact]
public async Task Help_flag_prints_usage_and_exits_0()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(new[] { "--help" }, o, e, EmptyEnv);
Assert.Equal(0, exit);
Assert.Contains("Usage:", o.ToString());
Assert.Equal("", e.ToString());
}
[Fact]
public async Task Short_help_flag_works()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(new[] { "-h" }, o, e, EmptyEnv);
Assert.Equal(0, exit);
Assert.Contains("--command", o.ToString());
}
[Fact]
public async Task Argument_error_prints_message_and_usage()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(new[] { "--bogus", "x" }, o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("--bogus", e.ToString());
Assert.Contains("Usage:", o.ToString());
}
[Fact]
public async Task Missing_command_exits_2()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(new[] { "--node", "x" }, o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("--command is required", e.ToString());
}
[Fact]
public async Task Params_must_be_valid_json()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--params", "not json" },
o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("not valid JSON", e.ToString());
}
[Theory]
[InlineData("[]")]
[InlineData("\"string\"")]
[InlineData("42")]
[InlineData("true")]
[InlineData("null")]
public async Task Params_must_be_object(string nonObject)
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--params", nonObject },
o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("must be a JSON object", e.ToString());
}
[Fact]
public async Task Connection_refused_exits_1_with_hint()
{
// Pick a port that's almost certainly closed.
var port = FindClosedPort();
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-port", port.ToString() },
o, e, EmptyEnv);
Assert.Equal(1, exit);
var stderr = e.ToString();
Assert.Contains("failed to reach MCP server", stderr);
Assert.Contains("Local MCP Server", stderr);
}
[Fact]
public async Task Successful_call_pretty_prints_payload_and_sends_correct_envelope()
{
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.OK,
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"sent\\\":true}\"}],\"isError\":false}}",
"application/json"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[]
{
"--command", "system.notify",
"--params", "{\"body\":\"hi\"}",
"--mcp-url", server.Url,
},
o, e, EmptyEnv);
Assert.Equal(0, exit);
Assert.Contains("\"sent\": true", o.ToString());
Assert.Equal("", e.ToString());
// Verify the wire format the server actually saw.
Assert.Equal("POST", server.LastRequestMethod);
Assert.StartsWith("application/json", server.LastRequestContentType ?? "");
using var sent = JsonDocument.Parse(server.LastRequestBody!);
Assert.Equal("2.0", sent.RootElement.GetProperty("jsonrpc").GetString());
Assert.Equal("tools/call", sent.RootElement.GetProperty("method").GetString());
var p = sent.RootElement.GetProperty("params");
Assert.Equal("system.notify", p.GetProperty("name").GetString());
Assert.Equal("hi", p.GetProperty("arguments").GetProperty("body").GetString());
}
[Fact]
public async Task Tool_error_response_writes_to_stderr_and_exits_1()
{
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.OK,
"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"camera offline\"}],\"isError\":true}}",
"application/json"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "camera.snap", "--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(1, exit);
Assert.Contains("camera offline", e.ToString());
}
[Fact]
public async Task Http_500_writes_status_and_body_to_stderr_and_exits_1()
{
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.InternalServerError, "kaboom", "text/plain"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(1, exit);
var stderr = e.ToString();
Assert.Contains("MCP HTTP 500", stderr);
Assert.Contains("kaboom", stderr);
}
[Fact]
public async Task Timeout_writes_message_and_exits_1()
{
using var server = new FakeMcpServer { HoldForever = true };
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[]
{
"--command", "x",
"--mcp-url", server.Url,
// CliRunner adds 5000ms buffer to the HTTP timeout, so keep this
// small so the test stays under a second.
"--invoke-timeout", "1",
},
o, e, EmptyEnv);
// The HttpClient timeout fires (1 + 5000 ms buffer = ~5s); test budget OK.
// Wider window for slow CI: the 5s ceiling matters only as an upper bound,
// not for correctness.
Assert.Equal(1, exit);
Assert.Contains("timed out", e.ToString());
}
[Fact]
public async Task Verbose_logs_endpoint_and_ignored_flags_to_stderr()
{
using var server = new FakeMcpServer();
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[]
{
"--node", "winbox-1",
"--idempotency-key", "abc",
"--command", "screen.list",
"--mcp-url", server.Url,
"--verbose",
},
o, e, EmptyEnv);
Assert.Equal(0, exit);
var stderr = e.ToString();
Assert.Contains(server.Url, stderr);
Assert.Contains("screen.list", stderr);
Assert.Contains("--node \"winbox-1\" ignored", stderr);
Assert.Contains("--idempotency-key ignored", stderr);
}
[Fact]
public async Task Verbose_without_node_or_key_omits_their_lines()
{
using var server = new FakeMcpServer();
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", server.Url, "--verbose" },
o, e, EmptyEnv);
Assert.Equal(0, exit);
var stderr = e.ToString();
Assert.DoesNotContain("--node", stderr);
Assert.DoesNotContain("--idempotency-key", stderr);
}
[Fact]
public async Task Endpoint_resolves_from_OPENCLAW_MCP_PORT_when_no_overrides()
{
using var server = new FakeMcpServer();
var env = (string key) => key == "OPENCLAW_MCP_PORT" ? server.Port.ToString() : null;
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list" },
o, e, env);
Assert.Equal(0, exit);
// The server received the request → env-based port resolution worked.
Assert.NotNull(server.LastRequestBody);
}
[Fact]
public async Task Loopback_only_for_auto_loaded_token()
{
// F-01: an auto-loaded (file:) token must NOT be sent to a non-loopback
// endpoint. We point --mcp-url at a non-loopback hostname (resolved
// back to the FakeMcpServer's loopback port) and assert no
// Authorization header was sent. The CLI should still complete the
// call (warning, not failure) and the warning text should be on stderr.
File.WriteAllText(Path.Combine(_sandboxDataDir, "mcp-token.txt"), "auto-loaded-token");
// Bind a fake server on a free loopback port; rewrite the URL host
// so the CLI sees a non-loopback hostname but the request still
// reaches the fake server. We use HttpClient's IP resolution by
// building the URL to actually hit 127.0.0.1, and assert via the
// CLI's loopback check (Uri.IsLoopback is false for any DNS host
// even if it resolves to 127.0.0.1).
using var server = new FakeMcpServer();
var nonLoopbackUrl = $"http://example.test:{server.Port}/";
// Plumb a delegating handler that rewrites example.test -> 127.0.0.1
// so the request actually lands on the fake server. The CLI sees
// example.test and applies its loopback check before the rewrite.
using var rewriter = new RewriteHandler(server.Port);
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", nonLoopbackUrl },
o, e, EmptyEnv,
httpHandler: rewriter);
// The server should have received a request, but with no auth header.
Assert.NotNull(server.LastRequestBody);
Assert.Null(server.LastRequestAuthorization);
Assert.Contains("refusing to send local MCP token", e.ToString());
Assert.Equal(0, exit);
}
[Fact]
public async Task Explicit_token_to_non_loopback_warns_but_sends()
{
// F-01: an explicit --mcp-token override is honored even off-loopback
// (the user took the action knowingly), but stderr still warns.
using var server = new FakeMcpServer();
var nonLoopbackUrl = $"http://example.test:{server.Port}/";
using var rewriter = new RewriteHandler(server.Port);
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", nonLoopbackUrl,
"--mcp-token", "explicit-token-987" },
o, e, EmptyEnv,
httpHandler: rewriter);
Assert.Equal(0, exit);
Assert.Equal("Bearer explicit-token-987", server.LastRequestAuthorization);
Assert.Contains("sending bearer token to non-loopback URL", e.ToString());
}
[Theory]
[InlineData("not a url at all")]
[InlineData("htttp://typo.example/")]
[InlineData("file:///c:/etc/passwd")]
[InlineData("ftp://example.com/")]
public async Task Invalid_mcp_url_exits_2(string url)
{
// F-09: --mcp-url must be an absolute http(s) URL. Other schemes /
// typos surface as exit 2 (argument error) before any HTTP traffic.
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", url },
o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("absolute http(s) URL", e.ToString());
}
[Fact]
public async Task Redirect_3xx_treated_as_error()
{
// F-02: HttpClient.AllowAutoRedirect is disabled. Any 3xx surfaces as
// an error; we never silently follow.
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.Redirect, "moved", "text/plain"),
};
// Set the Location header via a custom responder by switching to the
// generic post-process below — but FakeMcpServer doesn't expose that.
// For this test, just having status 302 is sufficient; HttpClient
// would normally chase a Location, but with AllowAutoRedirect=false,
// we get the raw 302 back.
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(1, exit);
Assert.Contains("redirect", e.ToString(), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Response_body_over_cap_is_rejected()
{
// F-03: cap response body at 16 MiB. A buggy/hostile server returning
// a multi-GB body must not OOM the CLI.
// We can't easily generate 16 MiB in a test, so synthesize a body
// larger than the cap via a custom responder. Use a smaller cap-test
// by changing nothing — instead, return a body that's slightly larger
// than 16 MiB (17 MiB) to trip the limit.
var oversized = new string('A', 17 * 1024 * 1024);
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.OK, oversized, "application/json"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(1, exit);
// Either the size cap surfaced as an explicit message, or HttpClient
// raised a generic exception that we surface via the "failed to reach"
// path. Either way, exit 1 and an explanatory stderr line.
var stderr = e.ToString();
Assert.True(stderr.Length > 0);
}
[Fact]
public async Task Params_at_path_loads_json_from_file()
{
// F-12: `--params @path` reads JSON from disk so big A2UI payloads /
// canvas.eval scripts don't have to fit on the command line.
var paramsPath = Path.Combine(_sandboxDataDir, "params.json");
File.WriteAllText(paramsPath, "{\"body\":\"loaded-from-file\"}");
using var server = new FakeMcpServer();
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "system.notify", "--params", "@" + paramsPath,
"--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(0, exit);
Assert.NotNull(server.LastRequestBody);
using var sent = JsonDocument.Parse(server.LastRequestBody!);
var args = sent.RootElement.GetProperty("params").GetProperty("arguments");
Assert.Equal("loaded-from-file", args.GetProperty("body").GetString());
}
[Fact]
public async Task Params_at_missing_path_exits_2()
{
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--params", "@C:/no/such/path.json" },
o, e, EmptyEnv);
Assert.Equal(2, exit);
Assert.Contains("failed to read", e.ToString());
}
[Fact]
public async Task Idempotency_key_warns_to_stderr_without_verbose()
{
// F-05: a copy-pasted gateway command including --idempotency-key
// must produce a stderr WARN even at default verbosity.
using var server = new FakeMcpServer();
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "screen.list", "--idempotency-key", "abc",
"--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(0, exit);
Assert.Contains("[winnode] WARN", e.ToString());
Assert.Contains("--idempotency-key ignored", e.ToString());
}
[Fact]
public async Task Error_body_with_control_chars_is_sanitized()
{
// F-16: ANSI escapes / CR-LF injection bytes from the server must be
// stripped before stderr emit so downstream log forwarders aren't
// tricked.
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.InternalServerError,
"error body \x1b[2Jinjected\x00\x07\x08hidden",
"text/plain"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--mcp-url", server.Url, "--verbose" },
o, e, EmptyEnv);
Assert.Equal(1, exit);
var stderr = e.ToString();
Assert.Contains("MCP HTTP 500", stderr);
// Server-supplied ANSI escapes / NUL / BEL / BS must be stripped so a
// hostile body can't smuggle ANSI clear-screen, fake hyperlinks, or
// log-line splits into stderr-consuming tooling. Verify by walking the
// bytes — xUnit's assertion message swallows non-printables, so a
// direct byte check reads better.
var rogue = stderr.FirstOrDefault(c => c < ' ' && c != '\n' && c != '\r' && c != '\t');
if (rogue != default(char))
{
var hex = string.Concat(stderr.Select(c => ((int)c).ToString("X2") + " "));
Assert.Fail($"Unexpected control char 0x{(int)rogue:X2} in stderr. Hex dump:\n{hex}");
}
// The literal payload bytes should still be visible (sanitize strips
// controls but preserves printable content).
Assert.Contains("[2Jinjected", stderr);
Assert.Contains("hidden", stderr);
}
[Fact]
public async Task Error_body_redacts_token_shaped_substrings()
{
// F-21: error bodies may legitimately echo paths, env values, or
// partial command output. Long base64url runs (≥32 chars) are
// redacted before emit so secrets don't leak into transcripts.
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.InternalServerError,
"leaked: AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_xyz end",
"text/plain"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--mcp-url", server.Url, "--verbose" },
o, e, EmptyEnv);
Assert.Equal(1, exit);
var stderr = e.ToString();
Assert.Contains("<redacted>", stderr);
Assert.DoesNotContain("AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", stderr);
}
[Fact]
public async Task Error_body_default_quiet_only_first_line()
{
// F-21: without --verbose, only the first line of an error body is
// echoed. Matches gh / kubectl behavior.
using var server = new FakeMcpServer
{
Responder = _ => (HttpStatusCode.InternalServerError,
"first line\nsecond line with details\nthird line",
"text/plain"),
};
var (o, e) = Buffers();
var exit = await CliRunner.RunAsync(
new[] { "--command", "x", "--mcp-url", server.Url },
o, e, EmptyEnv);
Assert.Equal(1, exit);
var stderr = e.ToString();
Assert.Contains("first line", stderr);
Assert.DoesNotContain("second line", stderr);
Assert.DoesNotContain("third line", stderr);
}
private static int FindClosedPort()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
var port = ((IPEndPoint)l.LocalEndpoint).Port;
l.Stop();
return port;
}
/// <summary>
/// Test helper: rewrite the request URI host from any DNS hostname to
/// 127.0.0.1 on the supplied port. Lets a test build a non-loopback URL
/// (so the CLI's loopback check sees it as off-box) while still having
/// the request actually reach the FakeMcpServer.
/// </summary>
private sealed class RewriteHandler : HttpClientHandler
{
private readonly int _port;
public RewriteHandler(int port)
{
_port = port;
AllowAutoRedirect = false;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var ub = new UriBuilder(request.RequestUri!) { Host = "127.0.0.1", Port = _port };
request.RequestUri = ub.Uri;
return base.SendAsync(request, cancellationToken);
}
}
}