Skip to content

WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft) - #16760

Closed
Mitch Denny (mitchdenny) wants to merge 43 commits into
microsoft:mainfrom
mitchdenny:feature/with-terminal
Closed

WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft)#16760
Mitch Denny (mitchdenny) wants to merge 43 commits into
microsoft:mainfrom
mitchdenny:feature/with-terminal

Conversation

@mitchdenny

Copy link
Copy Markdown
Member

Description

Adds the Aspire-side implementation of WithTerminal(...) — letting an
AppHost author opt a resource into a real interactive terminal session
that the dashboard, the aspire CLI, or any other HMP v1 viewer can
attach to and detach from at will.

End-to-end pipeline (Windows executables today; container/Linux/macOS
follow-ups tracked separately):

your process (cmd.exe / dotnet run / repl)
      ↓ stdout/stdin via ConPTY
DCP process_executable_runner_terminal       ← microsoft/dcp#133
      ↓ HMP1 server on dcp/r{i}.sock
Aspire.TerminalHost (Hex1bTerminal per replica)
   - WithHmp1UdsClient(producer)             ← consumes from DCP
   - WithHmp1UdsServer(consumer)             ← serves to viewers
      ↓ HMP1 server on host/r{i}.sock
viewers: dashboard /api/terminal proxy, `aspire terminal <name>`, …

This is a draft because:

  • DCP-side support is in microsoft/dcp#133 and not yet merged or version-bumped here.
  • IDE/debug execution is not terminal-attached yet (see "Known limitations").
  • Containers and Linux/macOS executables are follow-ups.

Tracks #16317.

What's in this PR

Public API (src/Aspire.Hosting/ApplicationModel)

  • WithTerminal() extension method + TerminalAnnotation / TerminalOptions /
    TerminalHostResource. Per-replica producer + consumer UDS layout owned by
    the TerminalHostResource; lifecycle is wired through
    TerminalHostEventingSubscriber.

DCP wire-up (src/Aspire.Hosting/Dcp/Model/TerminalSpec.cs,
src/Aspire.Hosting/Dcp/ExecutableCreator.cs)

  • New TerminalSpec mirrors the DCP API (Enabled / UdsPath / Cols / Rows).
  • ExecutableCreator populates spec.Terminal per replica from the layout
    on Windows when a TerminalAnnotation is present.
  • Bug fix in PreparePlainExecutables: plain executables now get
    ResourceReplicaCount=1 / ResourceReplicaIndex=0 annotations so the
    per-replica UDS lookup succeeds (without this, WithTerminal() on
    AddExecutable(...) silently no-op'd).

Out-of-process Terminal Host (src/Aspire.TerminalHost)

  • One Hex1bTerminal per replica acting as an HMP v1 client to DCP and an
    HMP v1 server to viewers. Native AOT-compatible.

Backchannel (src/Aspire.Hosting.Cli/... + src/Aspire.Cli/...)

  • AppHost exposes per-replica terminal info (resource name, replica index,
    consumer UDS path) over the existing CLI backchannel.

CLI (src/Aspire.Cli/Commands/TerminalCommand.cs)

  • aspire terminal <resource> [--replica N] connects to the consumer UDS
    via Hex1b's HMP v1 client and bridges the local terminal.

Dashboard (src/Aspire.Dashboard/...)

  • New TerminalView Blazor component (xterm.js + a thin JS module).
  • New /api/terminal WebSocket proxy (Terminal/TerminalWebSocketProxy.cs)
    bridging the browser WebSocket to the per-replica consumer UDS.
  • ConsoleLogs page swaps the log viewer for the terminal view when the
    selected resource has a terminal session, and now correctly rebinds the
    view when the user switches between terminal-enabled resources/replicas.

Playground (playground/Terminals/...)

  • Terminals.Repl — interactive ANSI REPL (help, whoami, time,
    size, echo, rainbow, clear, exit).
  • Terminals.AppHost — hosts the REPL with WithReplicas(2) +
    WithTerminal(120x32) and a Windows-gated shell resource
    (AddExecutable("cmd.exe") + WithTerminal()).

Spec

  • docs/specs/with-terminal.md documents the architecture, the per-replica
    UDS layout, and the lifecycle.

Validation

End-to-end requires the matching DCP build from
microsoft/dcp#133 and the
freshly-built Aspire.TerminalHost, both pointed at via env vars:

# 1. Build DCP from the PR branch (Go 1.22+)
git clone https://github.com/microsoft/dcp.git
cd dcp
git fetch origin mitchdenny/with-terminal-pty
git checkout mitchdenny/with-terminal-pty
go build -o bin/dcp.exe ./cmd/dcp

# 2. Build Aspire from this PR
git clone https://github.com/mitchdenny/aspire.git aspire-with-terminal
cd aspire-with-terminal
git fetch origin feature/with-terminal
git checkout feature/with-terminal
.\restore.cmd
.\build.cmd

# 3. Point Aspire at the local DCP and TerminalHost binaries
$env:ASPIRE_DCP_PATH           = "<path-to-dcp>\bin"
$env:ASPIRE_TERMINAL_HOST_PATH = "<path-to-aspire>\artifacts\bin\Aspire.TerminalHost\Debug\net8.0\Aspire.TerminalHost.exe"

# 4. Run the playground
cd playground\Terminals\Terminals.AppHost
dotnet run

Expected:

  • The dashboard shows three terminal-enabled resources: shell,
    repl-r0, repl-r1. Each entry's "Console Logs" tab renders a live
    xterm.js terminal instead of the log viewer.
  • Switching between resources/replicas in the resource selector swaps the
    attached terminal in place (state replay courtesy of Hex1bTerminal).
  • aspire terminal repl --replica 1 from a real conhost session attaches
    to replica 1 of the REPL with full interactivity.
  • Per-resource DCP logs at %LocalAppData%\Temp\aspire-dcp*\resource-executable-{guid}.log
    show Starting process under PTY... and Terminal session listening.

Unit / integration tests:

  • dotnet test tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj -- --filter-class "*.WithTerminalTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true" (15 pass)
  • --filter-method "*.Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows" --filter-method "*.Project_WithoutTerminal_HasNullTerminalSpec" --filter-method "*.PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows" (3 pass)

Known limitations / non-goals (deferred)

  • Debugger attach: when ExecutionType.IDE is in effect (project
    resources running under VS / VS Code), DCP's IDE runner ignores
    spec.Terminal and forwards the launch to the IDE, which wires
    stdin/stdout to its own debug console. The terminal view in the
    dashboard will be empty in that case. The Process runner fallback
    (no-debug, CLI scenarios) honors spec.Terminal and works as designed.
    Proper fix is cross-component (Aspire + DCP + VS / VSCode extension).
  • Linux/macOS executables: tracked as a follow-up against DCP
    (creack/pty).
  • Containers: tracked as a follow-up against DCP (docker/podman --tty).
  • Hello-frame dimensions: consumer UDS reports width:80,height:24
    even when WithTerminal(Cols=120,Rows=32) is set — needs
    investigation of whether DCP's PTY allocation or Hex1b's headless
    presentation is overriding the configured dims. Doesn't break
    rendering (xterm.js auto-fits), but should be fixed before shipping.
  • Persistent resources were called out in the issue — not in scope
    here, follow-up.

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected (DCP PR merge + version bump,
      Linux/macOS, containers, debugger-attach support).
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
      • If yes, did you have an API Review for it?
        • Yes
        • No (deferred until non-draft)
      • Did you add <remarks /> and <code /> elements on your triple slash comments?
        • Yes
        • No
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
      • If yes, have you done a threat model and had a security review?
        • Yes
        • No
    • No (UDS in user-owned temp dir; no network surface)
  • Does the change require an update in our Aspire docs?
    • Yes (after non-draft)
      • Link to aspire.dev issue: TBD
    • No

Companion PR

DCP-side: microsoft/dcp#133 — Add Windows PTY support for executables (HMP v1 over UDS)

@github-actions

github-actions Bot commented May 5, 2026

Copy link
Copy Markdown
Contributor

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 16760

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 16760"

@mitchdenny

Copy link
Copy Markdown
Member Author

Container support has been split out and stacked on top of this PR: #16762 (Aspire side) + microsoft/dcp#138 (DCP side).

Comment thread playground/Terminals/Terminals.sln Outdated

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this solution file add the playground projects to the main slnx files.

public bool SupportsV2 => _capabilities.Contains(AuxiliaryBackchannelCapabilities.V2);

/// <inheritdoc />
public bool SupportsTerminalsV1 => _capabilities.Contains(AuxiliaryBackchannelCapabilities.Terminals_V1);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to decide whether we rev the whole aux backchannel version for have a special capability just for this.

[JsonSerializable(typeof(GetPipelineStepsResponse))]
[JsonSerializable(typeof(GetTerminalInfoRequest))]
[JsonSerializable(typeof(GetTerminalInfoResponse))]
[JsonSerializable(typeof(TerminalReplicaInfo))]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we might end up consolidating each terminal into its own terminalhost or consolidating all terminals into one. So we can probably get rido f the idea of terminalreplicas and just have a 1:1 mappign to resources where we have resource names which include the replica details (like in the dashboard).

/// embedded HMP v1 wire-up and the role-aware InfoBar TUI.</item>
/// </list>
/// </remarks>
internal sealed class TerminalAttachCommand : BaseCommand

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also add a aspire terminal ps command. Regarding ps vs. ls - I think that ps makes sense because the terminal is someething that represents a running process.

The output should be a table with details about the resource, the terminal size, the number of attached frontends etc. This might require additional metadata to come across the backchannel.

/// <see cref="Hmp1ClientOptions.OnConnected"/> callback.
/// </para>
/// </remarks>
internal sealed class TerminalViewerApp

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first full alt screen TUI experience in the app. For these I think we should drop drop the file in an Aspire.Cli.Tui namespace.

{
await outer.RunAsync(_outerCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (_outerCts.IsCancellationRequested)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add diagnostic logging here.

await Task.WhenAny(disposeTask, timeout).ConfigureAwait(false);
}
}
catch { /* ignore */ }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnostic logging.

_embeddedCts.Dispose();
}
}
catch { /* ignore */ }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnotic logging.

ShowResourcePrefix="@_isSubscribedToAll"/>
@if (_selectedResourceHasTerminal && _terminalResourceName is not null)
{
<TerminalView @ref="_terminalViewRef" ResourceName="@_terminalResourceName" ReplicaIndex="@_terminalReplicaIndex" />

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Format this line similar to LogViewer

/// the terminal WebSocket because the proxy takes only
/// <c>resource</c>/<c>replica</c> identifiers.
/// </remarks>
internal sealed class DefaultTerminalConnectionResolver : ITerminalConnectionResolver

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this. Assuming we can get the UDS path, we can probalby just use that with WithHex1b...(...) on the hex1bterminal builder.

// down depending on the request state. Phase 9f hardening
// for the regression where Stop kills the dashboard.
logger.LogError(ex, "Terminal WebSocket handler {ConnectionId} crashed.", connectionId);
Console.Error.WriteLine($"[dashboard] terminal handler {connectionId} crashed: {ex.GetType().FullName}: {ex.Message}");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never use Console.Error.WriteLine ... always use the logger.

Comment thread src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs Outdated
Comment thread src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs
Comment thread src/Aspire.Hosting/Backchannel/TerminalHostControlClient.cs Outdated
Comment thread src/Aspire.Hosting/Dashboard/DashboardServiceData.cs
Comment on lines +104 to +105
[JsonPropertyName("terminal")]
public TerminalSpec? Terminal { get; set; }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Karol Zadora-Przylecki (@karolz-ms) and David Negstad (@danegsta) what else are we going to need in the terminal spec to support the full scope of what DCP can do we debugging with PTY attached?

Comment thread src/Aspire.Hosting/Dcp/Model/TerminalSpec.cs
{
if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows))
{
logger.LogWarning(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just because I have not yet tested on Linux. Should be able to do this early next week, I don't anticipate any problems as Linux support in Hex1b is stronger than Windows.

continue;
}

if (!File.Exists(terminalHostPath))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to package the terminal host like we package the dashboard - and resolve the terminal host path in a similar way.

Mitch Denny (mitchdenny) and others added 2 commits May 11, 2026 20:18
Refactor the TerminalHost model so each parent replica gets its own
`aspire.terminalhost` process owning a single producer/consumer/control
UDS triple, instead of a single host process owning N replica slots.

Why
---

The previous "one host with N replicas" design baked the replica count
into the host's argv (`--replica-count` plus repeated
`--producer-uds`/`--consumer-uds` arrays) and required the host to
dispatch incoming dials to the right replica slot. That made the host
replica-aware, but in practice the only thing that needed to know the
replica index was the AppHost (which generates the paths and tells DCP
which UDS each replica should dial). The host itself just needs to listen
on whatever UDS it is told to listen on.

This commit flips the model so:

- The host is replica-opaque: one process, one producer UDS, one consumer
  UDS, one control UDS. `--replica-count` is gone.
- The AppHost encodes the parent replica index into the per-replica
  directory of the layout (`{base}/{i}/{producer,host,control}.sock`),
  and creates one `TerminalHostResource` per replica named
  `{parent}-terminalhost-{i}`.
- `TerminalAnnotation` now exposes `IReadOnlyList<TerminalHostResource>
  TerminalHosts` (was a single `TerminalHost` reference).
- Backchannel `GetTerminalInfo`/`ListTerminals` fan out across the
  per-replica hosts in parallel via a shared `CollectReplicaInfosAsync`
  helper and degrade gracefully when individual hosts haven't started yet
  (each unreachable host yields a degraded `TerminalReplicaInfo` rather
  than failing the whole call).

Wire-shape changes
------------------

- `TerminalHostReplicasResponse` deleted from the protocol.
- `TerminalHostReplicaInfo` -> `TerminalHostSessionInfo` (no Index).
- `GetReplicasMethod` -> `GetSessionMethod`.
- `TerminalHostInfoResponse.ReplicaCount` removed.
- `TerminalHostControlProtocol.ProtocolVersion` bumped to 2.
- `TerminalSummary.IsHostReachable` semantics changed to "at least one
  per-replica host responded" (was "the single control RPC succeeded").
- `TerminalReplicaInfo.ReplicaIndex` is now sourced from
  `TerminalHostLayout.ParentReplicaIndex` rather than the host's reply.

Doc fixes
---------

The previous comments in `TerminalSpec.cs` claimed "DCP listens, host
dials". The truth is the opposite: TerminalHost LISTENS on the producer
UDS, and DCP DIALS into it. Same for the consumer UDS (viewers dial) and
the control UDS (AppHost dials). Comments are corrected to match.
The matching DCP-side comment fix in `terminal_types.go` is a separate
PR on the DCP repo that I'll do outside this session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces draft Aspire-side support for interactive, per-replica terminal sessions via a new .WithTerminal() app model API, backed by a new Aspire.TerminalHost process that bridges DCP PTY I/O to viewers (Dashboard + CLI) over Hex1b HMP v1, plus packaging/build infrastructure and tests.

Changes:

  • Add hosting/app-model API (WithTerminal, TerminalAnnotation, TerminalHostResource/Layout) and wire DCP executable/container specs to include a per-replica TerminalSpec (Windows-only today).
  • Add a new Aspire.TerminalHost executable + control protocol, and surface terminal metadata through the AppHost auxiliary backchannel (including CLI commands to attach/list).
  • Add Dashboard terminal UI plumbing (xterm.js view + server-side WS bridge) and template/build updates to ship a new Aspire.TerminalHost.Sdk.* pack.
Show a summary per file
File Description
tests/Shared/Aspire.Templates.Testing.targets Excludes TerminalHost SDK packs from unexpected-package assertions.
tests/Aspire.TerminalHost.Tests/TerminalHostArgsTests.cs Adds argument parsing unit tests for terminal host.
tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj New test project for Aspire.TerminalHost.
tests/Aspire.Templates.Tests/README.md Updates template test docs to mention TerminalHost SDK packs.
tests/Aspire.Hosting.Tests/WithTerminalTests.cs New unit tests covering WithTerminal annotation/resource/layout behavior.
tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs Asserts TerminalHostEventingSubscriber is registered by default.
tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs Adds DCP executor tests validating TerminalSpec wiring + replica annotations regression.
tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs Extends backchannel contract type list for terminal types.
tests/Aspire.Dashboard.Tests/Terminal/DefaultTerminalConnectionResolverTests.cs Adds tests for resolving terminal UDS connections from dashboard resource snapshots.
tests/Aspire.Dashboard.Tests/Model/ResourceViewModelExtensionsTerminalTests.cs Adds tests for terminal-related ResourceViewModel extension helpers.
tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs Registers terminal-related CLI commands in test DI setup.
tests/Aspire.Cli.Tests/TestServices/TestAppHostAuxiliaryBackchannel.cs Extends test backchannel stub with terminal RPCs/capabilities.
tests/Aspire.Cli.Tests/Commands/TerminalCommandViewerOptionTests.cs Tests parsing/help surface for terminal attach viewer mode.
tests/Aspire.Cli.Tests/Backchannel/BackchannelJsonSerializerContextTests.cs Adds serializer back-compat/round-trip tests for terminal backchannel types.
src/Shared/TerminalHost/TerminalHostControlProtocol.cs Adds shared terminal-host control protocol wire types/constants.
src/Shared/Model/KnownProperties.cs Adds terminal-related known property keys for dashboard snapshots.
src/Aspire.TerminalHost/TerminalHostControlRpcTarget.cs Implements StreamJsonRpc control target for terminal host.
src/Aspire.TerminalHost/TerminalHostControlListener.cs Adds UDS listener hosting StreamJsonRpc control connections.
src/Aspire.TerminalHost/TerminalHostArgs.cs Implements command-line parsing for terminal host.
src/Aspire.TerminalHost/TerminalHostApp.cs Implements the terminal host app lifecycle + replica startup/shutdown.
src/Aspire.TerminalHost/StderrLoggerProvider.cs Adds minimal stderr logger provider for terminal host.
src/Aspire.TerminalHost/Program.cs Adds terminal host executable entry point.
src/Aspire.TerminalHost/Aspire.TerminalHost.csproj New TerminalHost project definition (multi-RID publish).
src/Aspire.Managed/Program.cs Adds terminalhost subcommand dispatch to aspire-managed.
src/Aspire.Managed/Aspire.Managed.csproj References Aspire.TerminalHost from aspire-managed.
src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs Adds .WithTerminal() API and wiring to create hidden terminal host resources.
src/Aspire.Hosting/Lifecycle/TerminalHostEventingSubscriber.cs Resolves terminal host binary path/invocation args before DCP starts resources.
src/Aspire.Hosting/DistributedApplicationBuilder.cs Registers TerminalHostEventingSubscriber in hosting defaults.
src/Aspire.Hosting/Dcp/Model/TerminalSpec.cs Adds DCP model type for terminal configuration.
src/Aspire.Hosting/Dcp/Model/Executable.cs Adds Terminal block to executable spec JSON model.
src/Aspire.Hosting/Dcp/Model/Container.cs Adds Terminal block to container spec JSON model.
src/Aspire.Hosting/Dcp/ExecutableCreator.cs Populates per-replica terminal spec + fixes plain executable replica annotations.
src/Aspire.Hosting/Dcp/DcpOptions.cs Adds DcpOptions for terminal host discovery path/args and config resolution.
src/Aspire.Hosting/Dcp/ContainerCreator.cs Wires terminal spec for containers (Windows-only) when annotated.
src/Aspire.Hosting/Dashboard/DashboardServiceData.cs Stamps terminal properties into dashboard resource snapshots.
src/Aspire.Hosting/Backchannel/TerminalHostControlClient.cs Adds AppHost-side control client to query terminal host replicas over UDS.
src/Aspire.Hosting/Backchannel/BackchannelDataTypes.cs Adds terminal-related aux backchannel capability strings and payload types.
src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs Implements GetTerminalInfo/ListTerminals RPCs for terminal discovery.
src/Aspire.Hosting/Aspire.Hosting.csproj Links shared terminal host protocol types into Aspire.Hosting build.
src/Aspire.Hosting/ApplicationModel/TerminalHostResource.cs Adds hidden executable resource representing the terminal host process.
src/Aspire.Hosting/ApplicationModel/TerminalHostLayout.cs Defines per-replica producer/consumer/control UDS layout object.
src/Aspire.Hosting/ApplicationModel/TerminalAnnotation.cs Adds terminal annotation + configurable terminal options.
src/Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs Extends bundle resolution outputs to include terminal host path/args.
src/Aspire.Hosting.AppHost/build/Aspire.Hosting.AppHost.in.targets Emits assembly metadata for terminal host discovery (path + invocation args).
src/Aspire.Dashboard/wwwroot/js/xterm/xterm.min.css Adds xterm.js CSS asset to dashboard static content.
src/Aspire.Dashboard/wwwroot/js/xterm/addon-fit.min.js Adds xterm.js fit addon static asset.
src/Aspire.Dashboard/Terminal/NullTerminalConnectionResolver.cs Adds no-op resolver for cases where terminals aren’t available.
src/Aspire.Dashboard/Terminal/ITerminalConnectionResolver.cs Adds abstraction for server-side per-replica terminal connection resolution.
src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs Implements resolver via dashboard resource snapshot stream + UDS connect.
src/Aspire.Dashboard/Program.cs Adds dashboard lifecycle diagnostics and heartbeat logging.
src/Aspire.Dashboard/Model/ResourceViewModelExtensions.cs Adds helpers for detecting terminal availability + extracting replica/path info.
src/Aspire.Dashboard/DashboardWebApplication.cs Registers terminal resolver, enables websockets, and maps terminal WS endpoint.
src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor.cs Switches ConsoleLogs page logic to render terminal view when applicable.
src/Aspire.Dashboard/Components/Pages/ConsoleLogs.razor Renders TerminalView instead of LogViewer for terminal-enabled resources.
src/Aspire.Dashboard/Components/Controls/TerminalView.razor.cs Adds Blazor component to host xterm.js and reconnect across resource switches.
src/Aspire.Dashboard/Components/Controls/TerminalView.razor Adds terminal container markup for xterm host element.
src/Aspire.Dashboard/Aspire.Dashboard.csproj Adds Hex1b dependency needed for terminal WS bridging.
src/Aspire.Cli/Program.cs Registers terminal commands in CLI host DI wiring.
src/Aspire.Cli/Commands/TerminalCommand.cs Adds aspire terminal parent command.
src/Aspire.Cli/Commands/TerminalAttachCommand.cs Implements aspire terminal attach resource/replica selection and connect logic.
src/Aspire.Cli/Commands/RootCommand.cs Adds terminal command to CLI root command.
src/Aspire.Cli/Backchannel/IAppHostAuxiliaryBackchannel.cs Extends auxiliary backchannel interface with terminal capabilities + RPCs.
src/Aspire.Cli/Backchannel/BackchannelJsonSerializerContext.cs Adds source-gen JSON types for terminal backchannel payloads.
src/Aspire.Cli/Backchannel/AppHostAuxiliaryBackchannel.cs Implements CLI-side terminal backchannel calls + capability flags.
src/Aspire.Cli/Aspire.Cli.csproj Adds Hex1b dependency for CLI terminal attach.
src/Aspire.AppHost.Sdk/SDK/Sdk.in.targets Adds implicit reference to Aspire.TerminalHost.Sdk.* alongside Dashboard/DCP packs.
playground/Terminals/Terminals.Repl/Terminals.Repl.csproj Adds interactive REPL playground executable project.
playground/Terminals/Terminals.Repl/Program.cs Implements demo ANSI REPL commands used for terminal testing.
playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj Adds playground AppHost for terminal demo scenario.
playground/Terminals/Terminals.AppHost/Properties/launchSettings.json Adds local launch settings for terminal playground AppHost.
playground/Terminals/Terminals.AppHost/appsettings.json Adds base logging config for terminal playground.
playground/Terminals/Terminals.AppHost/appsettings.Development.json Adds dev logging config including Aspire.Hosting.Dcp warnings.
playground/Terminals/Terminals.AppHost/AppHost.cs Adds demo resources using WithTerminal (projects/executables/containers).
playground/Terminals/aspire.config.json Adds aspire.config.json for the terminal playground.
eng/terminalhostpack/UnixFilePermissions.xml Declares executable permissions for TerminalHost tool payload on Unix.
eng/terminalhostpack/Sdk.targets Adds MSBuild props/targets for TerminalHost SDK pack consumption.
eng/terminalhostpack/Sdk.props Adds empty SDK props placeholder.
eng/terminalhostpack/Common.projitems Adds packing logic to publish TerminalHost and include it in SDK packs.
eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.targets Enables buildTransitive import for TerminalHost SDK pack.
eng/terminalhostpack/buildTransitive/Aspire.TerminalHost.Sdk.in.props Enables buildTransitive import for TerminalHost SDK pack.
eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.targets Enables buildMultiTargeting import for TerminalHost SDK pack.
eng/terminalhostpack/buildMultiTargeting/Aspire.TerminalHost.Sdk.in.props Enables buildMultiTargeting import for TerminalHost SDK pack.
eng/terminalhostpack/AutoImport.props Adds AutoImport placeholder for TerminalHost SDK pack.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-x64.csproj Adds RID-specific packaging project for win-x64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.win-arm64.csproj Adds RID-specific packaging project for win-arm64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-x64.csproj Adds RID-specific packaging project for osx-x64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.osx-arm64.csproj Adds RID-specific packaging project for osx-arm64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-x64.csproj Adds RID-specific packaging project for linux-x64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-musl-x64.csproj Adds RID-specific packaging project for linux-musl-x64 TerminalHost SDK.
eng/terminalhostpack/Aspire.TerminalHost.Sdk.linux-arm64.csproj Adds RID-specific packaging project for linux-arm64 TerminalHost SDK.
eng/Publishing.props Adds TerminalHost artifact output dir and blob-feed publishing of zips.
eng/Build.props Adds terminalhostpack + TerminalHost project into bundle dependency build logic.
docs/specs/with-terminal.md Adds architecture/spec documentation for WithTerminal design and contracts.
Directory.Packages.props Updates Hex1b package versions to support terminal features.
Directory.Build.props Adds TerminalHost artifacts output dir + inner-loop TerminalHost dir setting.
Aspire.slnx Adds TerminalHost + terminal playground + terminal host tests to solution.
.github/workflows/build-cli-native-archives.yml Uploads TerminalHost SDK packs as CI artifacts alongside existing packs.

Copilot's findings

Comments suppressed due to low confidence (1)

tests/Aspire.Hosting.Tests/Backchannel/BackchannelContractTests.cs:45

  • New auxiliary backchannel data types were added (e.g., TerminalPeerInfo, TerminalSummary, ListTerminalsRequest/Response), but they aren’t included in s_contractTypes. That means the contract rules test won’t validate these new request/response types for sealed/init-only/required/nullability rules. Please add the new terminal-related types to s_contractTypes (and update the naming-convention exclusions if needed).
  • Files reviewed: 106/108 changed files
  • Comments generated: 6

Comment on lines +60 to +73
// Heartbeat task. Background, daemon-style: dies when the runtime tears
// down. We never await it; the loop's only job is to leave a periodic
// timestamp in stderr so the absence of one bounds the time of death.
_ = Task.Run(async () =>
{
var heartbeatInterval = TimeSpan.FromSeconds(10);
while (true)
{
try
{
Console.Error.WriteLine($"[dashboard] heartbeat {DateTimeOffset.UtcNow:O} pid={Environment.ProcessId}");
Console.Error.Flush();
await Task.Delay(heartbeatInterval).ConfigureAwait(false);
}
Comment on lines +49 to +53
// The actual consumer UDS path is *intentionally* not surfaced in the
// snapshot. The dashboard resolves it server-side via
// ITerminalConnectionResolver so an authenticated browser cannot coerce
// the dashboard into connecting to arbitrary UDS endpoints.
var terminalAnnotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault();
Comment on lines +13 to +18
/// The default registration is <see cref="NullTerminalConnectionResolver"/>, which
/// always returns <c>null</c>. The standalone dashboard uses this default since
/// terminal sessions are inherently a local-dev / DCP feature. The in-process
/// dashboard composition in <c>Aspire.Hosting</c> replaces this with a real
/// implementation that walks the resource graph and connects to the matching
/// <c>Hex1b.Hmp1</c> server.
Comment on lines +792 to +798
public async Task<GetTerminalInfoResponse> GetTerminalInfoAsync(string resourceName, CancellationToken cancellationToken = default)
{
if (!SupportsV2)
{
return new GetTerminalInfoResponse { IsAvailable = false };
}

Comment on lines +50 to +55
if (firstRender)
{
await InitializeTerminalAsync();
_connectedResourceName = ResourceName;
_connectedReplicaIndex = ReplicaIndex;
return;
Comment on lines +224 to +234
var msg = await ws.ReceiveAsync(buffer, token).ConfigureAwait(false);
if (msg.MessageType == WebSocketMessageType.Close)
{
return;
}

if (msg.Count > 0)
{
await upstream.WriteAsync(buffer.AsMemory(0, msg.Count), token).ConfigureAwait(false);
await upstream.FlushAsync(token).ConfigureAwait(false);
}
Mitch Denny (mitchdenny) and others added 3 commits May 11, 2026 22:50
Previously WithTerminal() read the parent resource's ReplicaAnnotation
eagerly and created the per-replica TerminalHostResources at the call
site. That meant calling WithReplicas(N) AFTER WithTerminal() would only
spawn one terminal host because the replica count was captured before
the model was finalized.

Move the materialization into a builder-phase BeforeStartEvent
subscription so the final ReplicaAnnotation count is always honoured,
regardless of call order. TerminalAnnotation is still added eagerly so
downstream consumers (DCP creators, dashboard data, backchannel) can
detect a configured terminal at WithTerminal() time; its TerminalHosts
collection is empty until BeforeStartEvent fires.

Builder-phase event subscriptions fire ahead of DI-registered
IDistributedApplicationEventingSubscriber instances, so
TerminalHostEventingSubscriber still sees every per-replica host in
the model when it runs and resolves their binary paths. The replica-
count drift warning in TerminalHostEventingSubscriber is removed
because it can no longer trigger.

Tests publish BeforeStartEvent manually before observing TerminalHosts.
A new regression test (WithReplicasAfterWithTerminalCreatesOneTerminalHostPerReplica)
exercises the bug fix directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an internal sibling WithTerminalForPolyglot<T>(builder) decorated with [AspireExport("withTerminal")] so non-C# AppHosts can call WithTerminal().

The original public WithTerminal<T>(builder, Action<TerminalOptions>?) overload keeps [AspireExportIgnore] (now pointing at the dispatcher) because Action<T> delegate parameters require ATS exporting TerminalOptions, which we deliberately keep out of the polyglot surface for now. Polyglot AppHosts that need to customise columns/rows/shell can fall back to per-resource environment variables until a future DTO-shaped overload lands.

Drive-by: removes a stray 'using Aspire.Hosting.Eventing' from WithTerminalTests.cs and DcpExecutorTests.cs that became unused after BeforeStartEvent moved to Aspire.Hosting.ApplicationModel; both files were failing IDE0005 as warnings-as-errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n bundle mode

When a TS-based AppHost (or any pre-built dotnet AppHost) is launched from the CLI bundle via PrebuiltAppHostServer/DotNetAppHostProject, the launcher only sets ASPIRE_DCP_PATH and ASPIRE_DASHBOARD_PATH (the latter pointing at the multi-mode aspire-managed exe). It does not set ASPIRE_TERMINAL_HOST_PATH, and the assembly-metadata fallback (aspireterminalhostpath) is empty for prebuilt AppHosts because the SDK targets that emit it never run in the TS scenario.

Result: TerminalHostEventingSubscriber sees an empty TerminalHostPath, logs a warning, and silently skips launching the per-replica terminal hosts — so .WithTerminal() resources have no terminal in bundle mode.

This change adds the same kind of bundle-aware fallback that DashboardEventHandlers has had since day one (where it detects IsAspireManagedBinary(DashboardPath) and prepends 'dashboard' as the dispatcher arg). After the explicit lookup chain runs, if TerminalHostPath is still empty AND DashboardPath points at aspire-managed, default TerminalHostPath = DashboardPath and TerminalHostInvocationArgs = 'terminalhost'. Standalone per-RID NuGet packages keep using their dedicated terminal host binary via assembly metadata and never hit this fallback. Explicit ASPIRE_TERMINAL_HOST_PATH / ASPIRE_TERMINAL_HOST_INVOCATION_ARGS still win.

New ConfigureDefaultDcpOptionsTests pin all four cases: bundle fallback fires, fallback does not fire for non-aspire-managed dashboards, explicit terminal host path is preserved, and explicit invocation args are preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mitchdenny

Copy link
Copy Markdown
Member Author

Jose Perez Rodriguez (@joperezr) can I get your feedback on the SDK related changes in this PR. I want to land this change early in the 13.5 cycle so we've got time to work as many of the kinks out. This PR depands on a custom build of DCP at the moment as well. It might be that this goes in in a broken state until that arrives, but provided it is not a problem to existing functionality that should be fine.

Mitch Denny (mitchdenny) added a commit that referenced this pull request Jun 3, 2026
Address Mitch's PR review comments on #16760 grouped under low-risk
cosmetic + logger-discipline fixes:

* TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The
  preceding logger.LogError already captures the exception with stack
  trace; the stderr writes were a belt-and-braces leftover from when
  ILogger plumbing was uncertain. Comment updated to reflect that.

* ConsoleLogs.razor: reformat the TerminalView element to match the
  multi-line attribute style used by the sibling LogViewer block.

* Move TerminalViewerApp from Aspire.Cli.Commands to a new
  Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder).
  This is the first full alt-screen TUI experience in the CLI; future
  TUI shells should land here too. TerminalAttachCommand picks it up
  via a new using directive.

* Replace the silent 'catch { /* ignore */ }' blocks in
  TerminalViewerApp.RunAsync with LogDebug-emitting catches so we
  have visibility when the embedded CTS teardown or Hex1bTerminal
  dispose actually fails (typical: object-disposed races, transport
  faults during shutdown, the 2s dispose-timeout masking a stuck
  pump). Also log when the outer Hex1bApp cancellation fires so we
  can distinguish embedded-fault vs caller-cancellation.

* Remove playground/Terminals/Terminals.sln; add the two playground
  projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a
  new /playground/Terminals/ folder slot, alphabetically between
  Stress and Testing, matching the convention used by every other
  playground.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mitchdenny

Copy link
Copy Markdown
Member Author

James Newton-King (@JamesNK) can I get your feedback on the Dashboard modifications that have been made in this PR. This is a pretty big PR that I want to get in early to derisk it for 13.5 (also depends on a DCP change).

@mitchdenny Mitch Denny (mitchdenny) left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review findings from a multi-area sweep (hosting / terminal-host / dashboard / CLI / tests).

Summary: 20 issues — 4 high (1 critical security: WS hijack), 8 medium, 8 low.

Posting as tracking comments since this PR is marked draft. None of these are blocking by themselves; the WS-origin and control-socket-perms ones should land before this leaves draft.

}
}
}
}).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical — Security] Cross-Site WebSocket Hijacking on /api/terminal

The terminal WS endpoint requires the standard frontend cookie-auth policy, but nothing validates the WebSocket upgrade's Origin header. Browsers do not apply the same-origin policy to WebSockets, and UseAntiforgery() does not gate WS upgrades (they're GET … Connection: Upgrade). With BrowserToken/Unsecured auth, any page loaded in the logged-in user's browser can new WebSocket("wss://localhost:<port>/api/terminal?resource=foo"), ride the auth cookie, and gain full read+write of any WithTerminal() shell — effectively RCE against the developer's machine.

app.UseWebSockets() (in DashboardWebApplication.cs:519) is called with no WebSocketOptions.AllowedOrigins; HandleAsync reads only resource/replica from the query string and never inspects context.Request.Headers.Origin.

Fix: populate WebSocketOptions.AllowedOrigins with the dashboard's own origin(s), or compare Request.Headers.Origin to the request's own scheme+host (and the configured frontend URLs) before calling AcceptWebSocketAsync, returning 403 on mismatch.

var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
socket.Bind(new UnixDomainSocketEndPoint(_socketPath));
socket.Listen(backlog: 5);
_socket = socket;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High — Security] Control UDS has no file-permission hardening — any local user can call shutdown (or getSession to leak peer display names)

The control socket is created and bound with no chmod and no explicit umask. Whatever the inherited process umask happens to be is what gates access. On a developer box with a permissive umask (e.g. 002/022) any local user with traverse + read access to the socket path can dial it and invoke ShutdownAsync (no auth check) or GetSessionAsync (echoes peer DisplayNames).

Fix: after Bind, call File.SetUnixFileMode(_socketPath, UserRead|UserWrite) (0600). Consider the same for the producer/consumer paths if Hex1b doesn't already do it.

// One temp base dir per parent: per-replica hosts get sub-directories beneath it
// (`{base}/{i}/...`) so the AppHost can clean up every host's sockets with a single
// recursive delete when the run ends.
var baseDir = Directory.CreateTempSubdirectory("aspire-term-").FullName;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High — Resource leak] Temp directory + UDS sockets are leaked every run

MaterializeTerminalHosts creates aspire-term-XXXXXXXX/ via Directory.CreateTempSubdirectory(...) and writes three .sock paths per replica beneath it. The XML docs on TerminalAnnotation.BaseDirectory and TerminalHostLayout explicitly state "the AppHost cleans up every host's sockets with a single recursive delete when the run ends" — but no such cleanup exists. There is no IDisposable/IAsyncDisposable on TerminalAnnotation, no shutdown event subscriber, and no try/finally around the directory creation. Each AppHost run leaks one directory plus 3×N stale UDS endpoints.

Compounds the sun_path-length issue below: stale paths near the limit make future runs nondeterministic.

Fix: wire AfterStopEvent (or IHostApplicationLifetime.ApplicationStopping) in TerminalHostEventingSubscriber to recursively delete terminalAnnotation.BaseDirectory.


return Hex1bTerminal.CreateBuilder()
.WithDimensions(Columns, Rows)
.WithWorkload(upstream)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Producer & consumer .sock files are never explicitly pre-deleted or post-removed by the host

Only the control listener pre-deletes its socket path (TerminalHostControlListener.cs:52-62) and post-deletes on dispose (line 208). For the producer and consumer UDS we trust Hex1b to do both, but the recycle-loop design depends on rebinding the same paths after a producer drop — if the previous bind left a stale file (process killed, OOM, crash before clean teardown), the next BuildTerminal will fail with EADDRINUSE and the loop will go into 5-second back-off forever while still reporting ready to the AppHost (see also TerminalHostApp.RunAsync finding).

The control-listener code proves the author knew this idiom was needed; the symmetry is missing on the two transport paths.

Fix: pre-delete the producer and consumer paths before invoking Hmp1Transports.ListenUnixSocket / WithHmp1UdsServer, and post-delete in TerminalReplica.DisposeAsync (best-effort, swallow IOException).

/// the same parent get unique paths while still sharing the parent's <paramref name="baseDir"/>
/// (which makes cleanup a single recursive delete).
/// </summary>
private static TerminalHostLayout CreateTerminalHostLayout(string baseDir, int replicaIndex)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] UDS sun_path length is never validated

Paths are built as {tmpBase}/aspire-term-XXXXXXXX/{replicaIndex}/{dcp|host|control}.sock. On macOS sockaddr_un.sun_path is 104 bytes (108 on Linux). With a default macOS $TMPDIR of /var/folders/xx/<28-char-hash>/T/ (~50 chars) + aspire-term- (12) + 8-char random (~20 total) + /0/control.sock (15) you're already around ~85 bytes — and once $TMPDIR is overridden (e.g. TMPDIR=/Users/<long-name>/Library/Caches/SomeTool/tmp) or replica indices reach 2+ digits, this can silently exceed the limit. Socket.Bind on the terminal-host side and Socket.ConnectAsync in TerminalHostControlClient will throw SocketException/EINVAL, masked by retry loops until timeout.

Fix: in CreateTerminalHostLayout, validate each produced path against the platform limit (OperatingSystem.IsMacOS() ? 104 : 108) and throw InvalidOperationException early, or fall back to a shorter base such as Path.Combine("/tmp", "asp-t-" + ShortHash()).

// size including SIGWINCH; the outer Hex1bTerminal is bound to
// those dims when running interactively.
var availW = Math.Max(1, Console.WindowWidth);
var availH = Math.Max(1, Console.WindowHeight - 1);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Render reads Console.WindowWidth/Height without the IOException guard used elsewhere

TryGetLocalDimensions (line 573-584) deliberately catches IOException because Console.WindowWidth throws in non-console contexts, but the per-frame Render reads the same properties unguarded. If aspire terminal attach is invoked with output redirected (e.g., piping to tee), or the controlling TTY is lost mid-session, an IOException propagates out of the render callback. There is also no precondition check rejecting redirected stdin/stdout before the TUI is launched for the single-replica path.

Fix: either wrap the Console.WindowWidth/Height reads in Render with the same try/catch fallback used by TryGetLocalDimensions, or add an IsInputRedirected || IsOutputRedirected precondition in TerminalAttachCommand.ExecuteAsync before constructing TerminalViewerApp and emit a clear error.

var model = app.Services.GetRequiredService<DistributedApplicationModel>();
await builder.Eventing.PublishAsync(new BeforeStartEvent(app.Services, model));
return (app, model);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low — Resource leak] BuildAndPublishBeforeStartAsync leaks the built DistributedApplication

The helper builds var app = builder.Build() and returns it but callers (lines 66, 85, 131, 146) discard it via var (_, model) = …. DistributedApplication is IAsyncDisposable; without disposal its background services, DCP-related objects, and any pooled handles stay live until GC finalization. The sibling helper PublishBeforeStartAsync correctly does using var app. Inconsistency aside, it accumulates undisposed DistributedApplication instances across the WithTerminal test suite.

Fix: return an IAsyncDisposable wrapper or have the helper take a callback (Func<DistributedApplicationModel, Task>) so using var app happens inside.

Assert.Equal(200, annotation.Options.Columns);
Assert.Equal(50, annotation.Options.Rows);
Assert.Equal("/bin/bash", annotation.Options.Shell);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low — Test misleading] WithTerminal_AcceptsCustomOptions never verifies the custom options propagate

The test sets Columns=200, Rows=50, Shell=/bin/bash, then only asserts those values on the annotation's Options — never publishing BeforeStartEvent and never checking that the per-replica TerminalHostResource actually surfaces them. The downstream propagation test (TerminalHostHasCommandLineArgsForLayoutPaths, line 268) does cover this, so this is not a coverage gap, but the test name overpromises what it actually validates. Flagging because this is the kind of test that gets cited later as proof "custom options are honored" when it only checks they're stored.

Fix: drop the test (the propagation test already covers the contract), or extend it to assert the materialized host carries the custom values.

{
return ReadOnlyMemory<byte>.Empty;
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low — Uncertain] ReadOutputAsync collapses three distinct end-states into ReadOnlyMemory<byte>.Empty

Empty is returned for: (a) already-disposed, (b) connect failure, (c) WaitToReadAsync returning false (channel completed = EOF), (d) TryRead race miss, and (e) ChannelClosedException. If IHex1bTerminalWorkloadAdapter's contract is that Empty means "EOF / shut down the workload", case (d) — a benign race after a wakeup with no successful TryRead — will cause Hex1b to tear down the workload spuriously. If Empty means "spurious, call me again", cases (b)/(c)/(e) will turn into a tight busy-loop. Couldn't verify the Hex1b contract from this repo.

Fix: confirm IHex1bTerminalWorkloadAdapter.ReadOutputAsync semantics; for (d) loop on TryRead until success or exit the wait; for (c)/(e) ensure the return value matches Hex1b's "workload terminated" sentinel.

CS8002: Disable strong name signing because Hex1b package is not signed.
CA2007: Do not directly await a Task in this exe code (we control the synchronization context).
-->
<NoWarn>$(NoWarn);CS1591;CS8002;CA2007</NoWarn>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low — Code comment] Aspire.TerminalHost.csproj suppresses CA2007 with an incorrect justification.

The comment says "we control the synchronization context" — but this is a console exe with no installed SynchronizationContext, so the suppression is fine, the rationale is wrong. The real reason is "no SyncContext, ConfigureAwait(false) is a no-op." Every await in this project still uses .ConfigureAwait(false) anyway, so the suppression is also redundant. Not a bug; flagging because it'll mislead the next reader trying to reason about thread-affinity invariants.

@mitchdenny

Copy link
Copy Markdown
Member Author

Closing in favour of a new PR from the team branch microsoft/aspire:feature/with-terminal (this PR currently points at the mitchdenny/aspire fork — GitHub doesn't allow changing a PR's head repo after open).

All review feedback from this PR will be re-posted as inline comments on the new PR, with resolution details for the 4 Critical/High items that have already been fixed (commits 028ca2793, 09352a82d, 09f2b4879, 8e9e4f174).

@microsoft-github-policy-service microsoft-github-policy-service Bot added this to the 13.5 milestone Jun 3, 2026
Mitch Denny (mitchdenny) added a commit that referenced this pull request Jun 4, 2026
Address Mitch's PR review comments on #16760 grouped under low-risk
cosmetic + logger-discipline fixes:

* TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The
  preceding logger.LogError already captures the exception with stack
  trace; the stderr writes were a belt-and-braces leftover from when
  ILogger plumbing was uncertain. Comment updated to reflect that.

* ConsoleLogs.razor: reformat the TerminalView element to match the
  multi-line attribute style used by the sibling LogViewer block.

* Move TerminalViewerApp from Aspire.Cli.Commands to a new
  Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder).
  This is the first full alt-screen TUI experience in the CLI; future
  TUI shells should land here too. TerminalAttachCommand picks it up
  via a new using directive.

* Replace the silent 'catch { /* ignore */ }' blocks in
  TerminalViewerApp.RunAsync with LogDebug-emitting catches so we
  have visibility when the embedded CTS teardown or Hex1bTerminal
  dispose actually fails (typical: object-disposed races, transport
  faults during shutdown, the 2s dispose-timeout masking a stuck
  pump). Also log when the outer Hex1bApp cancellation fires so we
  can distinguish embedded-fault vs caller-cancellation.

* Remove playground/Terminals/Terminals.sln; add the two playground
  projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a
  new /playground/Terminals/ folder slot, alphabetically between
  Stress and Testing, matching the convention used by every other
  playground.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mitch Denny (mitchdenny) added a commit that referenced this pull request Jun 10, 2026
…e, draft) (#17866)

* Add WithTerminal API: TerminalAnnotation, TerminalHostResource, and extension method

Implements Phase 1 of the live terminal support feature (#16317).

- TerminalAnnotation: IResourceAnnotation with TerminalOptions (Columns, Rows, Shell)
  and a SocketPath property for the UDS path set by the orchestrator.

- TerminalHostResource: Internal hidden resource (IResourceWithParent) that will
  manage the Hex1b-based terminal host process for a parent resource.

- WithTerminal<T>(): Extension method that adds TerminalAnnotation to a resource,
  creates a hidden TerminalHostResource, and adds a WaitAnnotation so the parent
  waits for the terminal host to be started.

- Tests: 8 unit tests covering annotation creation, custom options, hidden resource
  creation, wait annotation wiring, chaining, container support, manifest exclusion,
  and null argument handling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Aspire Terminal Protocol spec, shared codec, and playground

- docs/specs/terminal-protocol.md: Full protocol specification defining
  the binary framing format for terminal I/O over Unix domain sockets.
  Covers HELLO, DATA, RESIZE, EXIT, and CLOSE message types with wire
  examples and implementation notes for DCP (Go) and Aspire (C#).

- src/Shared/Terminal/: Shared protocol types (TerminalProtocol constants,
  TerminalFrameReader, TerminalFrameWriter, TerminalFrame) designed to be
  linked into Aspire.Hosting, Dashboard, and CLI projects.

- playground/Terminals/: Two-project playground demonstrating WithTerminal
  without DCP:
  - Terminals.TerminalHost: .NET console app using Hex1b with a custom
    IHex1bTerminalPresentationAdapter that implements the Aspire Terminal
    Protocol over UDS. Receives socket path via TERMINAL_SOCKET_PATH env var.
  - Terminals.AppHost: Aspire AppHost that launches the terminal host as
    a child process with a custom resource lifecycle (OnInitializeResource),
    demonstrating the full WithTerminal flow before DCP PTY support lands.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add terminal protocol test client and verify end-to-end

Terminals.Client: Standalone console app that connects to an Aspire
Terminal Protocol UDS server, performs the HELLO handshake, puts the
local console in raw mode, and bridges stdin/stdout bidirectionally.
Supports Ctrl+] to detach.

Verified end-to-end: TerminalHost starts pwsh with Hex1b PTY, listens
on UDS. Client connects, receives HELLO(v1, 80x24, Pty), and gets a
fully interactive PowerShell session with prompt rendering and command
execution working correctly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add DCP model TerminalSpec, backchannel RPC, and CLI terminal command

DCP Model:
- TerminalSpec: New model type with enabled, socketPath, columns, rows
- Added Terminal property to ExecutableSpec and ContainerSpec
- ExecutableCreator populates TerminalSpec from TerminalAnnotation

Backchannel:
- GetTerminalInfoRequest/Response in BackchannelDataTypes
- GetTerminalInfoAsync on AuxiliaryBackchannelRpcTarget (server)
- GetTerminalInfoAsync on AppHostAuxiliaryBackchannel (client)
- Added to IAppHostAuxiliaryBackchannel interface

CLI:
- New 'aspire terminal <resource>' command (TerminalCommand.cs)
- Connects to AppHost backchannel, gets terminal UDS path
- Connects to UDS, performs HELLO handshake
- Puts console in raw mode, bridges stdin/stdout bidirectionally
- Ctrl+] to detach, handles EXIT/CLOSE frames
- Registered in RootCommand and DI container

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add WithTerminal custom socket path provider overload

Adds a second WithTerminal overload that accepts a
Func<CancellationToken, Task<string>> socketPathProvider for resources
that manage their own terminal server (e.g., remote SSH, cloud resources).

Unlike the standard overload, this does NOT create a hidden
TerminalHostResource — the caller is responsible for running a server
that speaks the Aspire Terminal Protocol on the provided socket path.

Also adds SocketPathProvider property to TerminalAnnotation and
two new tests (10 total, all passing).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add Dashboard terminal support: xterm.js, WebSocket proxy, ConsoleLogs integration

Dashboard terminal view that replaces Console Logs for terminal-enabled resources:

- TerminalView.razor: Blazor component wrapping xterm.js via JS interop
- TerminalView.razor.js: xterm.js initialization, WebSocket connection, resize handling
- TerminalWebSocketProxy.cs: ASP.NET Core middleware at /api/terminal that bridges
  browser WebSocket to UDS using the Aspire Terminal Protocol (HELLO/DATA/RESIZE/EXIT/CLOSE)
- Vendored xterm.js 5.5.0 + fit addon in wwwroot/js/xterm/

ConsoleLogs integration:
- ConsoleLogs.razor: Conditionally renders TerminalView instead of LogViewer
  when the selected resource has terminal.enabled property
- ConsoleLogs.razor.cs: Detects terminal resources in SubscribeAsync,
  skips console log subscription for terminal resources

Infrastructure:
- KnownProperties.Terminal.Enabled/SocketPath constants in shared model
- DashboardServiceData: Injects terminal.enabled and terminal.socketPath
  properties into resource snapshots when TerminalAnnotation is present
- ResourceViewModelExtensions: HasTerminal() and TryGetTerminalSocketPath()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Dashboard terminal: use script tags for xterm.js and int IDs for JS interop

Two fixes for the Blazor unhandled error:
1. xterm.min.js is UMD format, not ES module — cannot use dynamic import().
   Changed to load via script tags into window.Terminal / window.FitAddon.
2. initTerminal returned a plain JS object which can't be marshaled as
   IJSObjectReference. Changed to return an int ID and use a Map-based
   registry on the JS side.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add production Aspire.TerminalHost and wire into AppHost discovery

New project: src/Aspire.TerminalHost/
- Console app using Hex1b with UnixDomainSocketPresentationAdapter
- Speaks Aspire Terminal Protocol over UDS
- Receives config via TERMINAL_SOCKET_PATH, TERMINAL_COLUMNS/ROWS/SHELL env vars
- Bridges PTY shell ↔ UDS clients

AppHost discovery (following Dashboard pattern):
- DcpOptions.TerminalHostPath for path resolution
- Three-tier discovery: env var (ASPIRE_TERMINAL_HOST_PATH) → config → assembly metadata
- Assembly metadata key: 'aspireterminalhostpath'
- MSBuild target SetTerminalHostDiscoveryAttributes in AppHost.in.targets
- Development path: artifacts/bin/Aspire.TerminalHost/{Config}/net8.0/

WithTerminal lifecycle:
- AddTerminalHostResource now generates UDS path and sets it on TerminalAnnotation
- OnInitializeResource resolves terminal host binary via DcpOptions
- Launches terminal host as child process with env var configuration
- Forwards stderr to resource logs
- Manages process lifecycle with clean shutdown

DCP flow:
- TerminalAnnotation.SocketPath → TerminalSpec on ExecutableSpec/ContainerSpec
- DCP receives terminal.enabled + terminal.socketPath in the CRD spec
- DCP can use socketPath to forward PTY I/O (Go implementation separate)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix playground: use custom socket path overload to avoid dual terminal host

The playground's TerminalDemoResource manages its own terminal host
process lifecycle, so it should use WithTerminal(socketPathProvider)
instead of the bare WithTerminal() which now also launches a terminal
host. Using the custom overload avoids creating a conflicting hidden
TerminalHostResource.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Redesign terminal host for reconnection with state replay

Replaces the presentation adapter approach with a presentation filter
architecture (modeled after Hex1b's DiagnosticsSocketListener):

- Terminal runs headless (Hex1b manages internal screen state)
- TerminalSocketServer is an IHex1bTerminalPresentationFilter that
  intercepts all output via OnOutputAsync and broadcasts to clients
- On client connect: CreateSnapshot().ToAnsi() captures current screen
  state and sends it as the first DATA frame after HELLO (with REPLAY flag)
- On client disconnect: terminal keeps running, accepts new connections
- On reconnect: fresh snapshot replayed, then live streaming resumes

This enables navigating away from the terminal in the Dashboard and
returning to find the same terminal state preserved.

Key changes:
- New TerminalSocketServer.cs (filter-based, session management)
- Program.cs: WithHeadless() + AddPresentationFilter(server) instead of
  WithPresentation(adapter)
- Old UnixDomainSocketPresentationAdapter kept for playground compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 1: refactor to per-replica UDS pair design

Refactor the WithTerminal API to match the new architecture decided for the
13.4 end-to-end work:

- TerminalAnnotation: drop SocketPath / SocketPathProvider; carry a strong
  reference to the hidden TerminalHostResource and the user-supplied
  TerminalOptions instead. The host owns its own UDS layout.

- TerminalHostResource: now public and derives from ExecutableResource so
  DCP launches it as a regular hidden executable. Carries a Parent
  reference plus the per-resource TerminalHostLayout. Constructed with a
  placeholder command (UnresolvedCommand) so we can rewrite it later.

- TerminalHostLayout (new): per-resource, per-run UDS layout — N producer
  paths under {tmp}/aspire-term-{guid}/dcp/, N consumer paths under
  host/, and one control.sock. Built via Directory.CreateTempSubdirectory
  per the repo temp-directory convention.

- TerminalHostEventingSubscriber (new): subscribes to BeforeStartEvent and
  resolves the real terminal host binary from DcpOptions.TerminalHostPath
  before DCP launches the resource. Emits a warning if the path is unset
  or if the parent's replica count drifted between WithTerminal() and
  start. Registered via TryAddEventingSubscriber in the builder.

- TerminalResourceBuilderExtensions: single overload, eager UDS layout,
  hidden host as ExecutableResource, args wired via a callback
  (--replica-count, --producer-uds xN, --consumer-uds xN, --control-uds,
  --columns, --rows, --shell). Adds a WaitAnnotation on the host
  (WaitUntilStarted for now; Phase 2 will upgrade to WaitUntilHealthy
  once the host exposes a health probe). Throws on double WithTerminal
  call.

- Stub leftovers in ExecutableCreator, AuxiliaryBackchannelRpcTarget, and
  DashboardServiceData for proper wire-up in Phases 4/5/7. Each stub is
  marked with a comment.

- Update WithTerminalTests to cover the new design (15 tests, all green).

- Drop playground/Terminals/Terminals.AppHost/TerminalDemoResource.cs and
  stub Terminals.AppHost itself; full playground rebuild lands in Phase 8.

- Add a GetTerminalInfoAsync stub on TestAppHostAuxiliaryBackchannel so
  the CLI test fake satisfies the interface.

Build is green with /p:SkipNativeBuild=true. WithTerminalTests pass 15/15.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 2: Aspire.TerminalHost on Hex1b HMP v1

Replaces the custom binary frame protocol with Hex1b 0.137 HMP v1 and
adopts the per-replica UDS pair design from Phase 1.

The host now creates one independent Hex1bTerminal per replica using
WithHmp1UdsClient(producerUds[i]).WithHmp1UdsServer(consumerUds[i]).
DCP runs the HMP v1 producer side; viewers (CLI / Dashboard) connect
to the consumer side. State replay on reconnect is handled by Hex1b.

A small StreamJsonRpc control listener on a separate UDS exposes
GetReplicas() and Shutdown() so the AppHost backchannel can populate
GetTerminalInfoAsync() without sharing the data plane.

The host no longer auto-exits when all replicas exit -- DCP owns the
host lifetime via cancellation or the control protocol.

Removed: src/Shared/Terminal/* (custom protocol), TerminalSocketServer,
UnixDomainSocketPresentationAdapter, playground/Terminals.TerminalHost.

Added: tests/Aspire.TerminalHost.Tests with 17 tests (12 args, 5 app)
covering arg parsing edge cases plus end-to-end control-listener +
replica startup over real UDS sockets. All 17 pass on Windows.

Phase 1 WithTerminalTests (15/15) still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 4: wire per-replica TerminalSpec into DCP ExecutableCreator

ExecutableCreator now populates spec.Terminal per replica when the resource has a TerminalAnnotation, indexing into TerminalHostLayout.ProducerUdsPaths via the ResourceReplicaIndex annotation. Gated on Windows for the 13.4 ship; logs a warning + skips on other platforms (Linux/macOS PTY support tracked as a follow-up).

TerminalSpec.cs aligned with the Go-side DCP API in microsoft/dcp PR #133: enabled / udsPath / cols / rows JSON tags with no client-side defaults (DCP applies 80x24 if zero).

Adds two DcpExecutorTests cases: Project_WithTerminal_PopulatesPerReplicaTerminalSpecOnWindows (assertion-rich, replica-ordered, Windows-gated) and Project_WithoutTerminal_HasNullTerminalSpec (negative case).

Tracking: microsoft/aspire#16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 5: backchannel exposes per-replica terminal endpoints

GetTerminalInfoAsync now opens the hidden terminal host's control UDS, calls getReplicas, and returns a TerminalReplicaInfo[] populated with the AppHost-canonical consumer UDS path for each replica. Connection retries are bounded by a 3 s budget so a request issued while DCP is still launching the host doesn't fail-fast.

Wire shape evolution is additive: SocketPath/Columns/Rows are preserved, Replicas is added as a new optional array. Older CLI builds that only check IsAvailable continue to work. New clients gate UI on the new terminals.v1 capability advertised by GetCapabilitiesAsync.

Out-of-range replica indices reported by the host are skipped with a warning so a buggy host can never crash a backchannel call.

Tests cover the resource-not-found, no-annotation, unreachable-host, happy-path, out-of-range-index, and capability-advertisement scenarios.

Tracking: microsoft/aspire#16317.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 6: aspire terminal CLI command on Hex1b HMP v1

Replaces the placeholder TerminalCommand with the full discovery, selection,
and attach flow defined in the Phase 6 plan.

- Add IAppHostAuxiliaryBackchannel.SupportsTerminalsV1 capability gate
  alongside the existing SupportsV2 surface so the CLI can fail fast against
  pre-13.4 AppHosts with a clear "Update Aspire.Hosting" message instead of
  a misleading "resource not found" or socket-connect error.
- Add Hex1b PackageReference to Aspire.Cli (already pinned via
  Directory.Packages.props; Hex1b 0.135+ is fully Native AOT-compatible so
  no new IL/AOT warnings are introduced versus the baseline).
- TerminalCommand flow:
    1. Resolve AppHost via AppHostConnectionResolver.
    2. Verify SupportsTerminalsV1; otherwise return AppHostIncompatible.
    3. Look up the resource via ResourceSnapshotMapper.WhereMatchesResourceName
       (matches by Name OR DisplayName so users can target replicated
       resources by the parent name).
    4. Canonicalise to the parent name (matches[0].DisplayName ?? Name) so
       GetTerminalInfoAsync receives the same identifier the AppHost knows.
    5. Call backchannel.GetTerminalInfoAsync.
    6. Pick a replica:
         - --replica/-r N    → exact match (out-of-range → InvalidCommand)
         - 1 replica         → auto-pick
         - non-interactive   → require --replica explicitly
         - interactive multi → PromptForSelectionAsync
    7. If the chosen replica has exited, warn and continue (the historical
       buffer is still served via HMP v1 StateSync).
    8. Hand off to Hex1bTerminal.CreateBuilder().WithHmp1UdsClient(...).Build()
       and await RunAsync. Ctrl+C cancels via the SCL cancellation token.
- Catches OperationCanceledException, SocketException, and
  IOException(SocketException) explicitly so connect failures and mid-session
  disconnects produce friendly messages instead of stack traces.

Tests (10 new in TerminalCommandTests):
- Help works.
- Missing resource argument fails parsing.
- No running AppHost returns Success (matches LogsCommand convention).
- Lacking SupportsTerminalsV1 returns AppHostIncompatible.
- Resource not found returns InvalidCommand.
- IsAvailable=false / empty replicas array return InvalidCommand.
- --replica out-of-range returns InvalidCommand.
- DisplayName lookup canonicalises to the parent resource name when calling
  GetTerminalInfoAsync (verified via a CapturingTerminalAppHostBackchannel
  decorator).
- Non-interactive multi-replica without --replica returns InvalidCommand.

The CLI test helper now registers TerminalCommand alongside the other
commands so the new tests can resolve the RootCommand.

* Phase 7: Dashboard /api/terminal WebSocket proxy + per-replica TerminalView

Wires the Aspire Dashboard end-to-end with the per-replica HMP v1 producer
endpoints introduced in Phase 1-5: the dashboard now exposes an authenticated
WebSocket endpoint at /api/terminal that proxies xterm.js byte streams to the
correct per-replica UDS without trusting any browser-supplied filesystem path.

Server side:

* New abstraction: Aspire.Dashboard.Terminal.ITerminalConnectionResolver
  exposes (resourceName, replicaIndex) -> Stream resolution. The default
  implementation (DefaultTerminalConnectionResolver) walks the live
  IDashboardClient.GetResources() snapshot, matches by display name +
  TryGetTerminalReplicaInfo, and connects via Hex1b
  Hmp1Transports.ConnectUnixSocket. NullTerminalConnectionResolver is kept
  as a hook for tests / unsupported hosts.

* TerminalWebSocketProxy is rewritten:
  - Endpoint /api/terminal is mapped with RequireAuthorization(Frontend)
    so only authenticated dashboard users can open a session.
  - Query string ?resource=&replica= is the only client-controlled state;
    the consumer UDS path is resolved server-side via the resolver, not
    accepted from the browser.
  - Two pumps:
      * inbound  (browser -> producer): binary frames carry keystroke
        bytes (forwarded as HMP v1 Input); text frames carry JSON resize
        control messages parsed via Utf8JsonReader.
      * outbound (producer -> browser): VT byte stream from the
        Hmp1WorkloadAdapter is sent as binary WS frames; resize hints
        from the producer become JSON text frames.
  - ReassembledFrame uses ArrayPool<byte> to handle multi-fragment WS
    reads without per-message allocation.
  - Graceful close via WebSocket.TryCloseAsync; resolver/protocol errors
    return an HTTP 5xx instead of leaking diagnostic detail.

* DefaultTerminalConnectionResolver registered as singleton in
  DashboardWebApplication.cs alongside the other resource-snapshot-aware
  services.

* Hex1b PackageReference added to Aspire.Dashboard.csproj (Hex1b 0.137,
  pinned via Directory.Packages.props).

Property contract:

* KnownProperties.Terminal: replaced the legacy single SocketPath constant
  with per-replica ReplicaIndex, ReplicaCount, and ConsumerUdsPath. The
  per-replica index is resolved from DcpInstancesAnnotation
  (DCP-allocated, stable) rather than parsing the random-suffixed
  resource name.

* ResourceViewModelExtensions: TryGetTerminalSocketPath is replaced by
  TryGetTerminalReplicaInfo(out int replicaIndex, out int replicaCount)
  and TryGetTerminalConsumerUdsPath(out string?).

* Aspire.Hosting/Dashboard/DashboardServiceData stamps these per-replica
  properties on each snapshot. ConsumerUdsPath is marked
  IsSensitive=true so the dashboard UI masks it; the value still rides
  the gRPC stream because it is required server-side, but it is never
  echoed back to the browser through the WS endpoint.

Browser side:

* TerminalView.razor.cs takes ResourceName + ReplicaIndex parameters
  instead of SocketPath; builds the WS URL as
  /api/terminal?resource=...&replica=... using the request's authority.
  ReconnectAsync(string?, int) is the new reconnect signature.

* TerminalView.razor.js sends keystrokes as binary frames via
  TextEncoder; resize messages remain text JSON. Framing is
  WS-frame-type-driven, not content sniffed.

* ConsoleLogs.razor / ConsoleLogs.razor.cs forward DisplayName +
  ReplicaIndex into TerminalView (no socket path leaves the server).

Tests:

* Aspire.Dashboard.Tests.Terminal.DefaultTerminalConnectionResolverTests
  covers: client-disabled, resource-not-found, replica mismatch,
  missing terminal-enabled marker, missing UDS path, and the
  bad-path-throws negative case.

* Aspire.Dashboard.Tests.Model.ResourceViewModelExtensionsTerminalTests
  covers HasTerminal, TryGetTerminalReplicaInfo, and
  TryGetTerminalConsumerUdsPath positive / negative paths.

Verified:

* dotnet build src/Aspire.Dashboard      -> 0 warnings, 0 errors
* dotnet build src/Aspire.Hosting        -> 0 warnings, 0 errors
* full ./build.cmd                       -> 0 warnings, 0 errors
* Aspire.Hosting.Tests *WithTerminal*    -> 16/16 passing
* Aspire.Cli.Tests *Terminal*            -> 11/11 passing
* Aspire.Dashboard.Tests *Terminal*      -> 13/13 passing
* Aspire.Dashboard.Tests (excl Playwright) -> 1255/1255 passing
* Aspire.Hosting.Tests Dashboard ns      -> 98/98 passing
* AOT publish: no new IL warnings from Terminal/* or TerminalView*
  (pre-existing Dashboard AOT warnings are unchanged; Dashboard is not
  AOT-compiled in the ship pipeline)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Phase 8: WithTerminal end-to-end playground + plain-executable fix

Wires up the Terminals playground end-to-end so WithTerminal() can be
exercised against both a project-style resource (REPL, 2 replicas) and a
plain executable (cmd.exe on Windows). Validated full pipeline:
DCP ConPTY -> HMP1 producer UDS -> Aspire.TerminalHost
(per-replica Hex1bTerminal) -> HMP1 consumer UDS -> external viewer.

Changes:
* New playground project Terminals.Repl: interactive ANSI REPL with
  help/whoami/time/size/echo/rainbow/clear/exit. Produces ANSI banner +
  prompt suitable for exercising terminal emulation through the full
  WithTerminal pipeline.
* Terminals.AppHost: add the REPL with WithReplicas(2) and
  WithTerminal(120x32); add a Windows-gated 'shell' resource
  (AddExecutable cmd.exe + WithTerminal()) to cover the plain-executable
  path.
* Bug fix in ExecutableCreator.PreparePlainExecutables: plain executables
  added via AddExecutable() were missing both ResourceReplicaIndex and
  ResourceReplicaCount annotations, which caused
  BuildExecutableConfiguration's per-replica producer UDS lookup to fail
  silently and skip the spec.Terminal wire-up entirely. Added regression
  test PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows.
* docs/specs/with-terminal.md: replaces the deleted
  terminal-protocol.md (which described an obsolete custom protocol) with
  the current Aspire-side architecture spec for WithTerminal().

Validation:
* Aspire.Hosting + Terminals.AppHost + Terminals.Repl all build clean
  (0 warnings 0 errors).
* All 15 WithTerminalTests pass.
* New PlainExecutable_WithTerminal_PopulatesTerminalSpecOnWindows test
  passes alongside existing Project_WithTerminal_/WithoutTerminal_ tests.
* Manually validated end-to-end with a small HMP1 probe: connecting to
  each consumer UDS yields a Hello frame and a multi-KB StateSync frame
  containing the live PTY output (cmd.exe banner / REPL banner).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Phase 8 fix: TerminalView reacts to resource/replica parameter changes

The Dashboard ConsoleLogs page reuses the same TerminalView instance
when the user switches between terminal-enabled resources (or between
replicas of the same resource), so firstRender is never true again
after the first switch. The original implementation only initialized
the xterm.js / WebSocket bridge on firstRender, which left the view
stuck on whichever resource was initially selected.

Track the (resource, replica) pair we last connected to, and call the
existing ReconnectAsync path from OnAfterRenderAsync whenever the
parameters change. The JS side already has reconnectTerminal which
closes the old WebSocket, clears the screen, and opens a new
connection — the StateSync replay from the new producer fills the
buffer with the right replica's content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal: container support + Node.js playground

ContainerCreator now mirrors the per-resource TerminalAnnotation -> DCP
TerminalSpec wiring that ExecutableCreator already does. Containers are
single-replica in DCP, so we always reference index 0 of the host's UDS
layout (the same layout the executable path uses for replica index 0).

The companion DCP-side change is in microsoft/dcp#138 (stacked on
microsoft/dcp#133): when ContainerSpec.Terminal is set, DCP creates the
container with `-t -i` and runs `docker start --attach --interactive`
under a host ConPTY exposing the resulting byte stream as an HMP v1
producer at TerminalSpec.UDSPath. The Aspire-side terminal host then
connects as an HMP v1 client, identical to the executable case.

Like the executable path, this is currently gated behind a Windows OS
check; on other platforms ContainerCreator logs a warning and leaves
TerminalSpec unset so the container runs without an attachable terminal.

Playground: adds a `nodebox` container resource (node:lts) with
`WithEntrypoint(""/bin/bash"")` so users can attach the dashboard
terminal and use `npx` / `node` interactively. Also re-enables
`WithTerminal()` on the existing `shell` (cmd.exe) executable
that was commented out for IDE-debug investigation in Phase 8.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Terminals playground: nodebox fix - override CMD instead of entrypoint

WithEntrypoint(""/bin/bash"") only overrides the image's ENTRYPOINT;
the image's CMD (""node"") is still inherited, so docker actually
executes `/bin/bash node` which makes bash treat `node` as a
missing script file and exit immediately - the container is gone
before the dashboard's Terminal tab even gets a chance to attach.

Switching to `WithArgs(""bash"", ""-l"")` keeps the image's
docker-entrypoint.sh in place and overrides the CMD, so the
entrypoint exec's an interactive login bash, which sticks around
for the terminal session and exits cleanly when the user types
`exit` (which then propagates through the host PTY's
docker-start-attach process and signals container exit via
Session.Done()).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Flip producer-side connection direction in Aspire.TerminalHost

Pairs with the matching DCP change. Previously the terminal host dialed
DCP (WithHmp1UdsClient) on the producer UDS; now the terminal host
LISTENS on that UDS and DCP dials in. This guarantees the host is
receiving from the very first byte the PTY emits, so a long-running
shell's initial prompt makes it into the host's scrollback even for
dashboard viewers that attach later.

The HMP v1 protocol roles are unchanged: DCP holds the PTY and so must
remain the HMP1 server; the terminal host remains the HMP1 client.
Hex1b's WithHmp1UdsClient/WithHmp1UdsServer convenience helpers couple
the HMP1 protocol role with the TCP role, which we don't want here. We
compose the lower-level WithHmp1Client(Func<CT, Task<Stream>>) with
Hmp1Transports.ListenUnixSocket(...) instead, taking the first stream
the listener accepts and using that as the HMP1 client transport.

The consumer side (WithHmp1UdsServer) is unchanged - the terminal host
keeps listening on the consumer UDS for dashboard/CLI viewers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Dashboard terminal: send resize on WebSocket open before render

xterm.js was constructed at the default 80x24 and only ever reported
the viewer's true dimensions to the host through term.onResize, which
fires when xterm INTERNAL dimensions change. The first fit() ran before
the WebSocket was open, so the resulting onResize was silently dropped
(state.ws null). Subsequent fits saw the same dimensions and didn't
fire onResize again. Net effect: the host received no Resize from the
viewer and replayed its initial StateSync at producer dimensions, so
when the viewer's xterm grid was larger than 80x24 the replayed content
appeared squeezed into a corner.

Fix: in ws.onopen, re-fit and explicitly send a resize JSON control
frame using the post-fit term.cols/term.rows. This is the first thing
the viewer sends to the host, guaranteeing that any post-handshake
StateSync re-emission (and all subsequent output) is rendered at the
viewer's actual viewport.

Refactored the onResize-driven send into a shared sendResize(state)
helper and reused it from both the onopen path and the term.onResize
hook.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal: process-restart support + Stop-regression hardening (Phase 9d-9f)

Phase 9d - process-restart support: TerminalReplica becomes a recycle loop
that disposes its Hex1bTerminal when the producer disconnects (process exit
or DCP-driven Stop), then immediately rebinds the same UDS paths so DCP can
relaunch the underlying process and the next producer dial reattaches viewers
without any out-of-band coordination.

  * Hex1b transports.ListenUnixSocket already unlinks stale socket files
    before binding so rebinding the same path is safe across cycles.
  * Adds ProducerConnected (live) and RestartCount (cumulative) state
    under a single lock; legacy IsAlive/ExitCode aliases retained for
    the CLI/AppHost callers - semantics shift from "permanently
    terminated" to "producer currently attached" but the slot itself
    outlives any single cycle, so the alias is still meaningful.
  * Escalating backoff (100ms -> 5s) on consecutive failed cycles so a
    wedged transport doesn't burn CPU/log indefinitely.
  * BuildTerminal() wrapped in its own try/catch with goto AfterRun;
    a transient build failure is treated as a failed cycle, not a
    permanently-disabled replica slot.

Phase 9d - control wire and clients:
  * TerminalHostReplicaInfo gains ProducerConnected and RestartCount
    (additive JSON, no protocol bump).
  * AuxiliaryBackchannelRpcTarget plumbs both fields through into
    TerminalReplicaInfo so the CLI and Dashboard can react to recycles.

Phase 9d - dashboard JS reconnect state machine:
  * TerminalView.razor.js rewritten with a single auto-reconnect loop and
    a generation token, so a late onclose from socket N can't schedule a
    reconnect on top of a freshly-connected socket N+1, and an explicit
    reconnectTerminal() (replica-switch path) safely interleaves with any
    pending auto-reconnect timer.
  * Each new connection clears xterm and reconstructs the stateful
    UTF-8 TextDecoder so StateSync replay paints into a clean buffer
    and tail bytes from the previous stream don't bleed into the next.
  * disposeTerminal() flips reconnect.enabled = false and bumps the
    generation so any late callbacks no-op.

Phase 9e/9f - hardening for user-reported "clicking Stop on a resource
kills the dashboard" regression:
  * TerminalWebSocketProxy.cs:
    - Removed fire-and-forget _ = adapter.ResizeAsync(...).AsTask().
      A resize that arrived during the producer-recycle window could
      throw an exception type (OperationCanceledException,
      InvalidOperationException from Hmp1Protocol mid-frame, etc.)
      that ResizeAsync's internal IOException/ObjectDisposedException
      catch list doesn't cover, leaving an unobserved task exception
      in the dashboard process. TryHandleControlFrame now returns a
      Task and is awaited from the inbound pump.
    - Broadened both pump exception filters from "WebSocketException
      or IOException or ObjectDisposedException" to a catch-all (with
      OperationCanceledException filtered as expected shutdown). The
      narrow filter was fine for the happy-path WS close but didn't
      cover an HMP1 protocol exception when the producer reset
      mid-frame (e.g. abrupt cmd.exe TerminateProcess on Windows).
    - 5-second handshake timeout via linked CTS so a wedged or
      mid-recycle host can't tie up WS handlers indefinitely while
      the JS retries in lockstep.
    - Wrapped BuildTerminal() inside the recycle loop in its own
      try/catch (Phase 9e).
  * TerminalView.razor.cs: OnAfterRenderAsync wraps ReconnectAsync in a
    catch-all (with JSDisconnectedException distinguished as benign);
    ReconnectAsync itself catches JSDisconnectedException around its
    InvokeVoidAsync calls so a JS-side error during the
    user-switched-resource path can't fail the SignalR circuit and
    tear down the entire dashboard tab.
  * TerminalView.razor.js: MAX_RECONNECT_ATTEMPTS = 30 cap on the
    auto-reconnect loop with a one-line "[terminal disconnected]"
    hint written into xterm so a permanently-stopped resource doesn't
    have the JS hammering the WS forever; state.term.clear() and
    state.term.dispose() wrapped in try/catch.
  * Program.cs: Wired AppDomain.UnhandledException and
    TaskScheduler.UnobservedTaskException to write the full type +
    message + stack trace to Console.Error so a future "dashboard
    silently died" report has breadcrumbs in the AppHost log instead
    of just silence; e.SetObserved() to neutralise the exception.
  * DashboardWebApplication.Run() catch-all now writes ex.ToString()
    not just ex.Message so a startup or run-time fatal is fully
    traceable from the AppHost log without a debugger attach.

All 7 TerminalHostAppTests pass. Aspire.Dashboard /
Aspire.TerminalHost / Aspire.Hosting build clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Phase 11: CLI wires up HMP1 multi-head primary/secondary protocol

The aspire terminal CLI now passes a displayName ('aspire-cli') and
defaultRole ('viewer' or 'interactive') in the HMP1 client hello, and
optionally requests primary on connect to drive PTY dimensions.

Behaviour:
- Default: connect as interactive and auto-RequestPrimary on handshake
  using local console dimensions (preserves single-head dogfood UX).
- `--viewer`: attach as a passive secondary; do not disturb whoever
  currently holds primary (typically the dashboard).

Subscribes to RoleChanged / PeerJoined / PeerLeft events for diagnostic
logging at debug level so multi-head behaviour can be traced without
adding production noise.

Bumps Hex1b/Hex1b.McpServer/Hex1b.Tool to 0.144.1-multihead1 from the
local-hex1b feed (PR microsoft/hex1b#xxx). Once Hex1b cuts a tagged
release containing the multi-head changes, this version + the
local-hex1b NuGet source override revert.

Tests: TerminalCommandViewerOptionTests verifies --viewer option
parsing and help text. Protocol-level frame emission is covered by
Hex1b's own multi-head test suite (Tier 2/3 in
tests/Hex1b.Tests/Hmp1/Hmp1MultiHead*.cs).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* TerminalHost: bridge consumer-side resize to raw DCP HMP1 frame

When a primary peer (e.g. CLI viewer or dashboard) sent RequestPrimary
to the consumer-side server, the resize never reached DCP. The previous
attempt cast the upstream workload to Hex1b's Hmp1WorkloadAdapter and
called ResizeAsync, but that method short-circuits with 'if (!IsPrimary)
return;' and IsPrimary only flips true on multi-head frames the minimal
DCP HMP1 server never sends.

Replace that path with DcpUpstreamAdapter, a narrow IHex1bTerminalWorkloadAdapter
that speaks raw HMP1 (Input + Resize outbound; Output/Hello/Exit inbound)
and writes FrameResize unconditionally. Wire the consumer-side
srvOpts.OnResized hook to upstream.ResizeAsync so primary-driven resizes
flow all the way through to DCP and the underlying PTY.

Adapter invariants:
- Single-shot connect via SemaphoreSlim + TaskCompletionSource.
- Atomic frame writes use the disposal CT (not caller CT) so a caller
  cancel mid-frame can't split header/payload and corrupt the stream.
- Disconnect fires exactly once (Interlocked.Exchange gate).
- Pre-connection resize coalesced under a gate; latest dims applied
  fire-and-forget after connect.
- Output channel uses BoundedChannelFullMode.Wait (terminal bytes are
  not message-independent; ANSI escapes can span buffers).

Regression test DownstreamPrimaryResizeIsForwardedUpstreamAsRawResizeFrame
dials a minimal raw-HMP1 producer and consumer to a real TerminalReplica,
sends ClientHello + RequestPrimary{cols=123,rows=45}, and asserts a
FrameResize matching those dims arrives upstream. Uses the new
WaitForMatchingFrameAsync helper to drain unrelated noise frames
(host's Hex1bTerminal also fires its own resize during the same flow).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Hex1b to 0.147.0 + transient nuget.org Hex1b-only mapping

Bumps Hex1b / Hex1b.McpServer / Hex1b.Tool from 0.144.1-multihead1
to 0.147.0 (multi-head GA wire — primary/secondary roles, ClientHello,
RequestPrimary, RoleChange, PeerJoin/PeerLeave). Wire is backward
compatible with the basic 0x01-0x06 frame subset that DCP's HMP1 server
speaks; the new 0x07-0x0B frames are aspire-internal multi-head
coordination only.

Adds a transient packageSourceMapping that scopes nuget.org to Hex1b
packages only, so we can consume 0.147.0 before the dotnet-public
mirror has caught up. Note in the XML comment makes the transience
explicit and the source/mapping should be removed once the internal
mirror has 0.147.0+.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Phase 17 Dashboard: transplant WebMuxerDemo terminal chrome + dumb byte-pipe pivot

Rewrites the dashboard terminal frontend around a new browser-side
HMP1 client that speaks the multi-head wire directly to the dashboard
WebSocket, which is now a dumb byte-pipe proxying straight to the
upstream terminal host's per-replica consumer UDS. Removes the prior
JS-interop frame translation layer.

Frontend (TerminalView.razor.js + new wwwroot/js/hmp1-client.js):
- Lifts the WebMuxerDemo experience: role infobar (primary / secondary
  pill + Take control button), font-size adjustment in the toolbar,
  resize-to-grid scaling that follows the host's Hello/Resize dims.
- Reconnect state machine, generation token, stateful UTF-8 decoder
  (re-created per connection), term.clear() on reconnect for StateSync
  replay.
- ES module imported via script tag with type=module; diagnostics gated
  by window.__aspireTerminalDebug = true.

Backend (TerminalWebSocketProxy.cs + Program.cs):
- Proxy is now a duplex byte-pipe: WS to upstream stream and back,
  no HMP1 parsing on the dashboard. Two-task pump with mutual
  cancellation; either side closing/erroring tears down the other.
- Logs at Information for forensics on Stop-cascade scenarios.
- Best-effort graceful WS close on bridge exit using CT.None so a
  shutdown abort doesn't skip the courtesy close.

All 13 dashboard terminal tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Phase 17 CLI: split TerminalCommand and lift WebMuxerDemo viewer experience

Splits the single `aspire terminal` command into a dispatcher
(TerminalCommand) plus a dedicated `aspire terminal attach` subcommand
(TerminalAttachCommand) that owns the interactive embedded-terminal
flow. New TerminalViewerApp class encapsulates the full WebMuxerDemo
viewer experience: role infobar (primary / secondary), Take-control
chord, font-size and presentation options, scrollback widget, clean
secondary detach when another peer takes primary.

TerminalCommand.cs is reduced to its dispatcher role only (322 lines
removed). TerminalAttachCommand.cs (new) wires the full HMP1 multi-head
client into Hex1bTerminal via the canonical
`.WithScrollback().WithTerminalWidget(out handle).Build()` pattern,
which avoids the WindowsConsoleDriver dependency in non-interactive
contexts. TerminalViewerApp.cs (new) implements the Hex1b widget chain
and wires Hmp1Client events into UI updates.

DI registrations updated in Program.cs and CliTestHelper.cs to register
the new TerminalAttachCommand alongside the existing TerminalCommand.

All 14 TerminalCommand* tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Terminals playground: shell2 control resource for Stop bisection + sln file

Adds a non-PTY 'shell2' executable (cmd.exe wrapping continuous ping)
to the Terminals.AppHost as an A/B control: same DCP-managed Windows
process model as 'shell' but WITHOUT WithTerminal(). Lets us bisect
whether 'Stop kills the dashboard' symptoms are PTY-attached-resource
specific or apply to any DCP-managed Windows process.

Adds Terminals.sln so the playground projects can be opened/managed
as a solution unit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update Hex1b 0.147.0 -> 0.150.0; drop transient nuget.org mapping

Hex1b 0.150.0 is now available on the internal dotnet-public feed, so we can remove the transient nuget-org-hex1b source and its scoped packageSourceMapping that was added to bridge the gap when Hex1b 0.147.0 was introduced. NuGet.config now matches origin/main; only Directory.Packages.props carries the version bump.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Adapt to Hex1b 0.150.0 and rebased main

Three small drift fixes surfaced by the post-rebase build:

1. Hex1b 0.150.0 renamed the input-binding fluent helper. `BackgroundPanelWidget.WithInputBindings` (Hex1b 0.147) was renamed to `InputBindings` in 0.150 (the breaking-change diff lists `InputBindingExtensions.WithInputBindings` removed and `InputBindings` added; signatures are otherwise identical).

2. `IDashboardClient.ExecuteResourceCommandAsync` gained an `ExecuteResourceCommandOptions options` parameter on main (#16903 "Support named resource command options"). The `DisabledDashboardClient` test fake in `DefaultTerminalConnectionResolverTests` needed the new signature.

3. `IAppHostAuxiliaryBackchannel.ExecuteResourceCommandAsync` likewise gained `ExecuteResourceCommandOptions? options` on main. The `CapturingTerminalAppHostBackchannel` test wrapper in `TerminalCommandTests` needed the new signature and to forward the new parameter through to the inner backchannel.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 18: package Aspire.TerminalHost as per-RID NuGet packages

Mirror the eng/dashboardpack/ pattern to ship Aspire.TerminalHost as
Aspire.TerminalHost.Sdk.<rid> packages alongside Aspire.Dashboard.Sdk.<rid>.

New eng/terminalhostpack/ contains:
  * 7 per-RID stub csprojs (win-x64/arm64, linux-x64/arm64/musl-x64, osx-x64/arm64)
  * Common.projitems that publishes Aspire.TerminalHost for the RID,
    packs the publish output under tools/, and emits a per-RID zip
  * Sdk.props/Sdk.targets/AutoImport.props markers
  * Sdk.targets sets AspireTerminalHostDir/AspireTerminalHostPath only
    when not already set (preserves inner-loop overrides)
  * UnixFilePermissions.xml grants 755 to tools/Aspire.TerminalHost
  * buildTransitive/ + buildMultiTargeting/ template wrappers

Wire-up:
  * Directory.Build.props: TerminalHostPublishedArtifactsOutputDir
  * eng/Publishing.props: duplicate property + publish glob + blob feed
  * eng/Build.props: BuildBundleDepsOnly + SkipBundleDeps + ProjectToBuild
  * src/Aspire.AppHost.Sdk: implicit Aspire.TerminalHost.Sdk.<rid>
    PackageReference inside AddReferenceToDashboardAndDCP
  * src/Aspire.TerminalHost.csproj: RuntimeIdentifiers + ReturnPackageVersion
    target so the per-RID pack projects can MSBuild it for publish

Inner-loop debugging is preserved by three independent layers:
  1. Directory.Build.props sets AspireTerminalHostDir to artifacts/bin/...
  2. In-repo playgrounds use Microsoft.NET.Sdk so AddReferenceToDashboardAndDCP
     never fires for them
  3. Sdk.targets guards each set with Condition checking that the property is empty

Verified: built Aspire.TerminalHost.Sdk.win-x64 standalone, inspected the
.nupkg, and confirmed tools/Aspire.TerminalHost.exe + tools/hex1bpty.exe
+ build/.../*.props|targets are all packaged with token substitution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 19: integrate Aspire.TerminalHost into the polyglot CLI bundle

The CLI bundle ships a single self-contained aspire-managed.exe that dispatches
into Dashboard / RemoteHost / NuGet helper modes via top-level switch in
Aspire.Managed/Program.cs. Add a fourth ["terminalhost", ...] arm so the same
binary handles WithTerminal()-spawned TerminalHost replicas in polyglot apps.

Bundle dispatcher:
  * Aspire.TerminalHost: TerminalHostApp class lifted to public so Aspire.Managed
    can call the static RunAsync(args, ct) entry point. Constructor and
    SnapshotReplicas remain internal so internal-only types (TerminalHostArgs,
    TerminalHostReplicaInfo) stay out of the public surface. CS1591 added to
    NoWarn since the assembly only ships as tools/ payload, not as a public API.
  * Aspire.Managed.csproj: ProjectReference to Aspire.TerminalHost (cross-TFM:
    Managed = net10.0 -> TerminalHost = net8.0, same as the existing Dashboard
    ProjectReference).
  * Aspire.Managed/Program.cs: ["terminalhost", .. var rest] arm calling
    TerminalHostApp.RunAsync; ShowUsage updated to advertise the new mode.

AppHost discovery flow:
  * Aspire.Hosting.Tasks/ResolveAspireCliBundle.cs: 3 new outputs
    AspireTerminalHostDir / AspireTerminalHostPath / AspireTerminalHostInvocationArgs.
    Path = ManagedPath (same aspire-managed.exe); InvocationArgs = "terminalhost".
  * Aspire.Hosting.AppHost.in.targets: ResolveAspireCliBundlePaths flows the new
    properties; SetTerminalHostDiscoveryAttributes also bakes an
    aspireterminalhostinvocationargs AssemblyMetadata when set.
  * Aspire.Hosting/Dcp/DcpOptions.cs: TerminalHostInvocationArgs property +
    metadata key + ASPIRE_TERMINAL_HOST_INVOCATION_ARGS env var resolution.
  * Aspire.Hosting/Lifecycle/TerminalHostEventingSubscriber.cs: when invocation
    args are set, prepend each via a CommandLineArgsCallbackAnnotation. Mirrors
    the Dashboard pattern (DashboardEventHandlers args.Insert(0, "dashboard")).

Inner-loop preservation: the per-RID Aspire.TerminalHost.exe path remains the
inner-loop default. AspireTerminalHostInvocationArgs is empty unless the bundle
discovery task ran (AspireUseCliBundle=true), so the prepend is a no-op for
in-repo playgrounds.

Verified:
  * Built playground/Terminals/Terminals.AppHost and inspected its assembly
    metadata: aspireterminalhostpath = artifacts/bin/Aspire.TerminalHost/.../Aspire.TerminalHost.exe
    (no aspireterminalhostinvocationargs, as expected for inner-loop).
  * Published Aspire.Managed for win-x64 self-contained and invoked
    aspire-managed.exe terminalhost --help: dispatcher correctly routed into
    TerminalHostApp.RunAsync (which replied with its own argument-parsing
    error, proving the arm wires through end-to-end). aspire-managed.exe with
    no args also lists the new "terminalhost" subcommand in its usage banner.
  * Targeted tests: Aspire.TerminalHost.Tests 20/20, Aspire.Hosting.Tests
    terminal subset 15/15, Aspire.Cli.Tests terminal subset 14/14,
    Aspire.Dashboard.Tests terminal subset 6/6, Aspire.Managed.Tests 3/3.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* docs/specs/with-terminal: fix MD040 fenced-code-language

Add `text` language to the unlabelled process-topology fence at line 23
so the markdownlint job (CI / Markdownlint) passes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 20: fix CI failures from Phase 18/19

Three small follow-ups to make CI green:

1. .github/workflows/build-cli-native-archives.yml: add Aspire.TerminalHost.Sdk.<rid>.*.nupkg to the upload-artifact glob. The packages were being produced by the build (eng/Build.props wires terminalhostpack into the bundle-deps build) but stripped from the per-RID artifact, so the Templates tests' built-local feed never saw them and dotnet restore failed with NU1101: Unable to find package Aspire.TerminalHost.Sdk.linux-x64.

2. tests/Aspire.Hosting.Tests/DistributedApplicationBuilderTests.cs: extend the BuilderAddsDefaultServices Assert.Collection to include the new TerminalHostEventingSubscriber registered by DistributedApplicationBuilder.cs (Phase 19).

3. tests/Shared/Aspire.Templates.Testing.targets: add Aspire.TerminalHost.Sdk. to the UnexpectedPackages exclusion list, mirroring the existing Aspire.Dashboard.Sdk., Aspire.Hosting.Orchestration., and Aspire.Cli. exclusions.

Plus the README mention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 22: review feedback cleanup pass 1

Address Mitch's PR review comments on #16760 grouped under low-risk
cosmetic + logger-discipline fixes:

* TerminalWebSocketProxy: drop Console.Error.WriteLine fallback. The
  preceding logger.LogError already captures the exception with stack
  trace; the stderr writes were a belt-and-braces leftover from when
  ILogger plumbing was uncertain. Comment updated to reflect that.

* ConsoleLogs.razor: reformat the TerminalView element to match the
  multi-line attribute style used by the sibling LogViewer block.

* Move TerminalViewerApp from Aspire.Cli.Commands to a new
  Aspire.Cli.Tui namespace (and matching src/Aspire.Cli/Tui/ folder).
  This is the first full alt-screen TUI experience in the CLI; future
  TUI shells should land here too. TerminalAttachCommand picks it up
  via a new using directive.

* Replace the silent 'catch { /* ignore */ }' blocks in
  TerminalViewerApp.RunAsync with LogDebug-emitting catches so we
  have visibility when the embedded CTS teardown or Hex1bTerminal
  dispose actually fails (typical: object-disposed races, transport
  faults during shutdown, the 2s dispose-timeout masking a stuck
  pump). Also log when the outer Hex1bApp cancellation fires so we
  can distinguish embedded-fault vs caller-cancellation.

* Remove playground/Terminals/Terminals.sln; add the two playground
  projects (Terminals.AppHost, Terminals.Repl) to Aspire.slnx in a
  new /playground/Terminals/ folder slot, alphabetically between
  Stress and Testing, matching the convention used by every other
  playground.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 22: review feedback documentation pass

Address Mitch's PR review comments that resolve to 'document the
existing decision/architecture' rather than code changes:

* AppHostAuxiliaryBackchannel.cs: explain the per-feature capability
  flag pattern (Terminals_V1) and link to docs/specs/cli-backchannel.md
  §3, which already prescribes capability strings over monolithic
  version revs. The current implementation already follows that, but
  the rationale was not visible from the code itself, leading to the
  'rev whole channel vs. per-feature?' question on review.

* TerminalWebSocketProxy.cs: add a 'Why a custom proxy and not Hex1b's
  Hmp1PresentationAdapter?' section to the class XML doc. Explains
  that Hmp1PresentationAdapter is the *server* side of HMP1 (lives in
  the process owning the terminal), and WebSocketPresentationAdapter
  is for in-process Hex1b apps that render *themselves* via WS — the
  dashboard fits neither role because it sits between two HMP1
  endpoints and relays at the byte level. The original doc cross-
  referenced WebMuxerDemo's WebSocketProxy.BridgeAsync, which has been
  removed from Hex1b; that line is dropped.

* DefaultTerminalConnectionResolver.cs: explain why the dashboard
  reaches for the lower-level Hmp1Transports.ConnectUnixSocket helper
  instead of the WithHmp1UdsClient builder (the builder embeds an
  HMP1 stream into a Hex1b terminal, which is the CLI viewer's
  pattern — the dashboard doesn't run a terminal).

No behavioural changes; doc-only.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* WithTerminal Phase 23: aspire terminal ps command + per-replica metadata

Adds 'aspire terminal ps' which lists every WithTerminal()-enabled resource
in the connected AppHost with current grid size, attached-peer count, and
per-replica health.

Wire formats / capabilities
- New 'terminals.ps.v1' capability advertised by AuxiliaryBackchannelRpcTarget
  alongside the existing 'terminals.v1'. Older AppHosts that only know
  'terminals.v1' continue to work for 'terminal attach' but the CLI
  surfaces a clear AppHostIncompatible error when 'terminal ps' is invoked
  against them.
- TerminalReplicaInfo gains four nullable optional fields (CurrentColumns,
  CurrentRows, AttachedPeerCount, Peers). Old payloads round-trip with
  null new fields; new payloads round-trip with values populated. See
  docs/specs/cli-backchannel.md sect 3 for the per-feature capability
  rationale.
- New TerminalPeerInfo, ListTerminalsRequest, TerminalSummary,
  ListTerminalsResponse types registered in BackchannelJsonSerializerContext.

TerminalHost-side metadata tracking
- TerminalReplica wires Hex1b's Hmp1ServerOptions OnClientConnected /
  OnClientDisconnected / OnResized callbacks via WithHmp1UdsServer to
  maintain a peer dictionary and current dimensions (under a lock). The
  OnResized callback preserves the existing upstream resize-forwarding
  behavior. Peers are cleared defensively in cycle teardown to handle a
  late disconnect callback.
- TerminalHostApp.SnapshotReplicas surfaces the new per-replica info.

AppHost RPC
- New ListTerminalsAsync method on AuxiliaryBackchannelRpcTarget iterates
  every TerminalAnnotation-bearing resource and aggregates per-replica
  info. Per-resource try/catch with a 3s timeout: a single host that
  errors becomes IsHostReachable=false rather than failing the listing.

CLI
- New TerminalPsCommand in src/Aspire.Cli/Commands/. Mirrors the
  TerminalAttachCommand resolver pattern, supports --apphost / --project,
  --format text|json (mirroring PsCommand's OutputFormat enum), and
  --verbose|-v which adds a second per-peer details table. Empty list
  short-circuits with a friendly text/JSON message. Spectre table
  columns: Resource | Replica | Status | Size | Peers | Restarts.
- TerminalPsCommand registered in DI in Program.cs and wired into
  TerminalCommand as a subcommand alongside TerminalAttachCommand.

Tests
- 5 new TerminalPsCommand tests in TerminalCommandTests covering: no
  AppHost running, capability gate, empty list, populated list, and
  --format json on empty.
- 2 new BackchannelJsonSerializerContext tests: old payload without new
  fields deserializes with nulls (back-compat), and full
  ListTerminalsResponse with new fields round-trips cleanly.
- TestAppHostAuxiliaryBackchannel and the inline CapturingTerminal
  fake in TerminalCommandTests grew SupportsTerminalsPsV1 +
  ListTerminalsAsync stubs to satisfy the new interface members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Per-replica TerminalHost: one host process per parent replica

Refactor the TerminalHost model so each parent replica gets its own
`aspire.terminalhost` process owning a single producer/consumer/control
UDS triple, instead of a single host process owning N replica slots.

Why
---

The previous "one host with N replicas" design baked the replica count
into the host's argv (`--replica-count` plus repeated
`--producer-uds`/`--consumer-uds` arrays) and required the host to
dispatch incoming dials to the right replica slot. That made the host
replica-aware, but in practice the only thing that needed to know the
replica index was the AppHost (which generates the paths and tells DCP
which UDS each replica should dial). The host itself just needs to listen
on whatever UDS it is told to listen on.

This commit flips the model so:

- The host is replica-opaque: one process, one producer UDS, one consumer
  UDS, one control UDS. `--replica-count` is gone.
- The AppHost encodes the parent replica index into the per-replica
  directory of the layout (`{base}/{i}/{producer,host,control}.sock`),
  and creates one `TerminalHostResource` per replica named
  `{parent}-terminalhost-{i}`.
- `TerminalAnnotation` now exposes `IReadOnlyList<TerminalHostResource>
  TerminalHosts` (was a single `TerminalHost` reference).
- Backchannel `GetTerminalInfo`/`ListTerminals` fan out across the
  per-replica hosts in parallel via a shared `CollectReplicaInfosAsync`
  helper and degrade gracefully when individual hosts haven't started yet
  (each unreachable host yields a degraded `TerminalReplicaInfo` rather
  than failing the whole call).

Wire-shape changes
------------------

- `TerminalHostReplicasResponse` deleted from the protocol.
- `TerminalHostReplicaInfo` -> `TerminalHostSessionInfo` (no Index).
- `GetReplicasMethod` -> `GetSessionMethod`.
- `TerminalHostInfoResponse.ReplicaCount` removed.
- `TerminalHostControlProtocol.ProtocolVersion` bumped to 2.
- `TerminalSummary.IsHostReachable` semantics changed to "at least one
  per-replica host responded" (was "the single control RPC succeeded").
- `TerminalReplicaInfo.ReplicaIndex` is now sourced from
  `TerminalHostLayout.ParentReplicaIndex` rather than the host's reply.

Doc fixes
---------

The previous comments in `TerminalSpec.cs` claimed "DCP listens, host
dials". The truth is the opposite: TerminalHost LISTENS on the producer
UDS, and DCP DIALS into it. Same for the consumer UDS (viewers dial) and
the control UDS (AppHost dials). Comments are corrected to match.
The matching DCP-side comment fix in `terminal_types.go` is a separate
PR on the DCP repo that I'll do outside this session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: whitespace nudge to retrigger CI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Defer per-replica TerminalHost creation to BeforeStartEvent

Previously WithTerminal() read the parent resource's ReplicaAnnotation
eagerly and created the per-replica TerminalHostResources at the call
site. That meant calling WithReplicas(N) AFTER WithTerminal() would only
spawn one terminal host because the replica count was captured before
the model was finalized.

Move the materialization into a builder-phase BeforeStartEvent
subscription so the final ReplicaAnnotation count is always honoured,
regardless of call order. TerminalAnnotation is still added eagerly so
downstream consumers (DCP creators, dashboard data, backchannel) can
detect a configured terminal at WithTerminal() time; its TerminalHosts
collection is empty until BeforeStartEvent fires.

Builder-phase event subscriptions fire ahead of DI-registered
IDistributedApplicationEventingSubscriber instances, so
TerminalHostEventingSubscriber still sees every per-replica host in
the model when it runs and resolves their binary paths. The replica-
count drift warning in TerminalHostEventingSubscriber is removed
because it can no longer trigger.

Tests publish BeforeStartEvent manually before observing TerminalHosts.
A new regression test (WithReplicasAfterWithTerminalCreatesOneTerminalHostPerReplica)
exercises the bug fix directly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add ATS export for WithTerminal via parameterless dispatcher overload

Adds an internal sibling WithTerminalForPolyglot<T>(builder) decorated with [AspireExport("withTerminal")] so non-C# AppHosts can call WithTerminal().

The original public WithTerminal<T>(builder, Action<TerminalOptions>?) overload keeps [AspireExportIgnore] (now pointing at the dispatcher) because Action<T> delegate parameters require ATS exporting TerminalOptions, which we deliberately keep out of the polyglot surface for now. Polyglot AppHosts that need to customise columns/rows/shell can fall back to per-resource environment variables until a future DTO-shaped overload lands.

Drive-by: removes a stray 'using Aspire.Hosting.Eventing' from WithTerminalTests.cs and DcpExecutorTests.cs that became unused after BeforeStartEvent moved to Aspire.Hosting.ApplicationModel; both files were failing IDE0005 as warnings-as-errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fall back to ASPIRE_DASHBOARD_PATH when terminal host path is unset in bundle mode

When a TS-based AppHost (or any pre-built dotnet AppHost) is launched from the CLI bundle via PrebuiltAppHostServer/DotNetAppHostProject, the launcher only sets ASPIRE_DCP_PATH and ASPIRE_DASHBOARD_PATH (the latter pointing at the multi-mode aspire-managed exe). It does not set ASPIRE_TERMINAL_HOST_PATH, and the assembly-metadata fallback (aspireterminalhostpath) is empty for prebuilt AppHosts because the SDK targets that emit it never run in the TS scenario.

Result: TerminalHostEventingSubscriber sees an empty TerminalHostPath, logs a warning, and silently skips launching the per-replica terminal hosts — so .WithTerminal() resources have no terminal in bundle mode.

This change adds the same kind of bundle-aware fallback that DashboardEventHandlers has had since day one (where it detects IsAspireManagedBinary(DashboardPath) and prepends 'dashboard' as the dispatcher arg). After the explicit lookup chain runs, if TerminalHostPath is still empty AND DashboardPath points at aspire-managed, default TerminalHostPath = DashboardPath and TerminalHostInvocationArgs = 'terminalhost'. Standalone per-RID NuGet packages keep using their dedicated terminal host binary via assembly metadata and never hit this fallback. Explicit ASPIRE_TERMINAL_HOST_PATH / ASPIRE_TERMINAL_HOST_INVOCATION_ARGS still win.

New ConfigureDefaultDcpOptionsTests pin all four cases: bundle fallback fires, fallback does not fire for non-aspire-managed dashboards, explicit terminal host path is preserved, and explicit invocation args are preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Drop TerminalSpec.Enabled and Windows-only gate

Apply Karol's review feedback from microsoft/dcp#133:

A.1 — Drop the Enabled flag on TerminalSpec. Presence of the field on an
Executable or Container spec is now sufficient to activate the terminal
path; the parallel "Enabled = false" state had no defined semantics.
ExecutableCreator and ContainerCreator stop setting it. The DCP-side
companion change in api/v1/terminal_types.go removes the field.

While here, drop the IsOSPlatform(OSPlatform.Windows) gate that was
suppressing spec.Terminal on Linux/macOS — DCP now implements PTY
allocation on all three host platforms (ConPTY on Windows, /dev/ptmx on
Unix). The previous-warning behaviour on non-Windows is gone; if the
running DCP build does not support terminal allocation the executable
will fail to start with termpty.ErrTerminalNotSupported surfaced through
the reconciler instead.

The two DcpExecutor tests that asserted on .Enabled have been updated
to assert presence of the spec instead, and their stale Windows-only
SkipUnless guards have been removed (they run against
TestKubernetesService and never required real DCP).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* playground: add Unix bash shell + auto-wire TerminalHost project ref

- AppHost.cs: add an 'shell' executable on the non-Windows branch that
  spawns an interactive login bash (-i -l), mirroring the cmd.exe branch
  on Windows so the macOS/Linux playground exercises the executable PTY
  path. Drop the now-stale comment about DCP being Windows-only.
- Terminals.AppHost.csproj: conditionally project-reference
  Aspire.TerminalHost (ReferenceOutputAssembly=false) and stamp
  AspireTerminalHostPath as an assembly metadata attribute so the
  AppHost finds the freshly-built terminal host binary without anyone
  having to set ASPIRE_TERMINAL_HOST_PATH or DcpPublisher:TerminalHostPath
  manually. Mirrors the existing SkipDashboardProjectReference pattern;
  same CI/out-of-repo guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Post-rebase fixups + bump Hex1b 0.154 → 0.161

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Dashboard: lift terminal chrome into ConsoleLogs toolbar

Move the status badge, take-control button, font ± controls, and size
dropdown out of the in-frame terminal chrome and into the existing
ConsoleLogs page toolbar. The toolbar block is gated on the resource
having a terminal annotation, so log-only resources are unaffected.

- TerminalView.razor.js: strip footer/controls bar + CSS; replace
  updateChrome/updateFooterControls with a RAF-coalesced, change-detected
  notifyToolbar that pushes snapshots up via DotNetObjectReference.
  Add exported wrappers (takePrimaryFromHost, setFontSizeFromHost,
  setSizeModeFromHost, getSizePresets, getToolbarState). initTerminal
  now takes (element, wsUrl, dotNetRef).
- TerminalView.razor.cs: add DotNe…
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 3, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants