Skip to content

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

Draft
mitchdenny wants to merge 22 commits intomicrosoft:mainfrom
mitchdenny:feature/with-terminal
Draft

WithTerminal(): per-replica interactive terminal sessions (Aspire side, draft)#16760
mitchdenny wants to merge 22 commits intomicrosoft: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)

mitchdenny and others added 18 commits April 20, 2026 11:25
…xtension method

Implements Phase 1 of the live terminal support feature (microsoft#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>
- 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>
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>
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>
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>
…s 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>
…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>
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>
…l 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>
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>
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>
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>
…leCreator

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 microsoft#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#16317 + microsoft/dcp#6 + DCP PR microsoft/dcp#133.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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#16317.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
…alView

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>
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>
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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

🚀 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"

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>
@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).

mitchdenny and others added 3 commits May 5, 2026 14:56
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>
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant