Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Each session can have a list of "run commands" — labelled command lines invoke
**Data:** `ShellSession.RunCommands: List<RunCommandItem> { Id, Label, CommandLine, IsDefault, Mode, PostRunUrl }`. Exactly one item has `IsDefault=true`; see `RunCommandItem.EnsureSingleDefault`. Persisted to `state.json`.

- **`Mode`** (`RunMode.Process` default / `RunMode.PowerShell`) — `Process` runs through `cmd /c` as before; `PowerShell` wraps the command line in `pwsh.exe -NonInteractive -NoLogo -ExecutionPolicy Bypass -EncodedCommand <utf16le-b64>` (falls back to `powershell.exe` if `pwsh` isn't on PATH). SSH parents ignore `Mode` — remote runs always go through bash. Use PowerShell when the command relies on pipes (`|`), redirection (`>`), `$env:` variables, or cmdlets.
- **`PostRunUrl`** (`string?`, default `null`) — when set and the run exits with code 0, `Process.Start` opens the URL via `UseShellExecute=true` (default browser). Failures are swallowed; no health-check polling.
- **`PostRunUrl`** (`string?`, default `null`) — when set and the run exits with code 0, `Process.Start` opens the URL via `UseShellExecute=true` (default browser). No health-check polling. The value is gated by `RunInstance.IsLaunchableUrl` first: **only absolute `http`/`https` URLs are launched.** ShellExecute would otherwise run a local exe, a `.ps1`, a UNC path or any registered protocol handler, and this fires automatically with no confirmation — and `ImportExportService` deserializes a whole `AppState` (run commands included) from any JSON file the user points at, so the stored value is not trusted. Rejections and launch failures both append to `crash.log`; neither pops UI, since this runs on the PTY-exit callback thread. Scheme-less input (`localhost:5173`) is rejected rather than guessed at.

**Templates:** `RunCommandTemplatesService.SeedFor(folder)` detects project type (top-level scan, first-match: dotnet → cargo → node → python → make) and returns a seed list with fresh Ids. Templates are *copied* onto new sessions at creation time; subsequent edits don't propagate back. SSH sessions skip detection (empty list).

Expand Down
28 changes: 25 additions & 3 deletions src/CodeShellManager/Services/RunInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,34 @@ private void OnPtyExited()
// so failures are logged to crash.log for diagnosability rather than silenced.
if (State == RunState.ExitedOk && !string.IsNullOrWhiteSpace(PostRunUrl))
{
if (!IsLaunchableUrl(PostRunUrl))
{
LogPostRunUrl(PostRunUrl, "rejected — only http and https URLs are opened");
return;
}
try { Process.Start(new ProcessStartInfo(PostRunUrl) { UseShellExecute = true }); }
catch (Exception ex) { LogPostRunUrlFailure(PostRunUrl, ex); }
catch (Exception ex) { LogPostRunUrl(PostRunUrl, ex.Message); }
}
}

private static void LogPostRunUrlFailure(string url, Exception ex)
/// <summary>
/// True when <paramref name="url"/> is safe to hand to ShellExecute — an absolute
/// http or https URL, and nothing else.
///
/// This fires automatically when a run exits 0, with no confirmation step, and
/// ShellExecute will happily launch a local executable, a .ps1, a UNC path or any
/// registered protocol handler. A whole AppState — run commands included — can be
/// imported from a JSON file the user didn't write (see ImportExportService), so the
/// scheme is checked at launch time rather than trusting the stored value.
///
/// Scheme-less input like "localhost:5173" is rejected too: Uri parses it as scheme
/// "localhost", and guessing http:// on the user's behalf would defeat the check.
/// </summary>
internal static bool IsLaunchableUrl(string? url) =>
Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);

private static void LogPostRunUrl(string url, string detail)
{
try
{
Expand All @@ -165,7 +187,7 @@ private static void LogPostRunUrlFailure(string url, Exception ex)
"CodeShellManager", "crash.log");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.AppendAllText(path,
$"[{DateTime.Now:HH:mm:ss.fff}] PostRunUrl failed '{url}': {ex.Message}\n");
$"[{DateTime.Now:HH:mm:ss.fff}] PostRunUrl '{url}': {detail}\n");
}
catch { /* logger failure is not actionable */ }
}
Expand Down
2 changes: 1 addition & 1 deletion src/CodeShellManager/Views/SessionRunCommandsDialog.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
Background="#313244" Foreground="#cdd6f4" BorderBrush="#45475a"
CaretBrush="#cdd6f4" Padding="6,4" Margin="0,0,8,0"
VerticalContentAlignment="Center"
ToolTip="Optional. e.g. http://localhost:5173 — opens in the default browser when the command exits 0."/>
ToolTip="Optional. Opens in the default browser when the command exits 0. Must be a full http:// or https:// URL — e.g. http://localhost:5173, not localhost:5173."/>
<StackPanel Grid.Column="5" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="▲" Tag="{Binding}" Click="MoveUp_Click" Width="26" Height="24"
Background="Transparent" BorderThickness="0" Foreground="#a6adc8" Cursor="Hand"
Expand Down
50 changes: 50 additions & 0 deletions tests/CodeShellManager.Tests/PostRunUrlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using CodeShellManager.Services;
using Xunit;

namespace CodeShellManager.Tests;

/// <summary>
/// PostRunUrl is handed to ShellExecute automatically when a run exits 0 — no
/// confirmation step. ShellExecute launches whatever the string resolves to, so
/// anything that isn't an http(s) URL must be rejected before it reaches
/// Process.Start. See RunInstance.IsLaunchableUrl.
/// </summary>
public class PostRunUrlTests
{
[Theory]
[InlineData("http://localhost:5173")]
[InlineData("https://example.com")]
[InlineData("http://127.0.0.1:5000/health")]
[InlineData("https://example.com/path?q=1&r=2#frag")]
[InlineData("HTTPS://EXAMPLE.COM")] // scheme comparison is case-insensitive
public void IsLaunchableUrl_HttpAndHttps_Accepted(string url)
{
Assert.True(RunInstance.IsLaunchableUrl(url));
}

[Theory]
[InlineData(@"C:\Windows\System32\calc.exe")] // parses as scheme "c"
[InlineData(@"C:\scripts\deploy.ps1")]
[InlineData(@"\\server\share\payload.exe")] // UNC → file scheme
[InlineData("file:///C:/Windows/System32/calc.exe")]
[InlineData("ftp://example.com/x")]
[InlineData("javascript:alert(1)")]
[InlineData("ms-settings:")] // registered protocol handler
[InlineData("steam://run/440")] // third-party handler
public void IsLaunchableUrl_NonHttpSchemes_Rejected(string url)
{
Assert.False(RunInstance.IsLaunchableUrl(url));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("localhost:5173")] // scheme-less: parses as scheme "localhost"
[InlineData("example.com")] // scheme-less: not an absolute URI at all
[InlineData("/relative/path")]
public void IsLaunchableUrl_EmptyOrSchemeless_Rejected(string? url)
{
Assert.False(RunInstance.IsLaunchableUrl(url));
}
}