Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
760bc81
Add WithTerminal API: TerminalAnnotation, TerminalHostResource, and e…
mitchdenny Apr 20, 2026
266dba6
Add Aspire Terminal Protocol spec, shared codec, and playground
mitchdenny Apr 20, 2026
c080697
Add terminal protocol test client and verify end-to-end
mitchdenny Apr 20, 2026
4abf58a
Add DCP model TerminalSpec, backchannel RPC, and CLI terminal command
mitchdenny Apr 20, 2026
b949355
Add WithTerminal custom socket path provider overload
mitchdenny Apr 20, 2026
30de0e5
Add Dashboard terminal support: xterm.js, WebSocket proxy, ConsoleLog…
mitchdenny Apr 20, 2026
8f98f41
Fix Dashboard terminal: use script tags for xterm.js and int IDs for …
mitchdenny Apr 20, 2026
834a53c
Add production Aspire.TerminalHost and wire into AppHost discovery
mitchdenny Apr 20, 2026
e4ab7d4
Fix playground: use custom socket path overload to avoid dual termina…
mitchdenny Apr 20, 2026
40b3d9b
Redesign terminal host for reconnection with state replay
mitchdenny Apr 20, 2026
f238c34
WithTerminal Phase 1: refactor to per-replica UDS pair design
mitchdenny May 4, 2026
a792901
WithTerminal Phase 2: Aspire.TerminalHost on Hex1b HMP v1
mitchdenny May 4, 2026
e0b743c
WithTerminal Phase 4: wire per-replica TerminalSpec into DCP Executab…
mitchdenny May 4, 2026
04200ea
WithTerminal Phase 5: backchannel exposes per-replica terminal endpoints
mitchdenny May 5, 2026
e70fd25
WithTerminal Phase 6: aspire terminal CLI command on Hex1b HMP v1
mitchdenny May 5, 2026
79f2f69
Phase 7: Dashboard /api/terminal WebSocket proxy + per-replica Termin…
mitchdenny May 5, 2026
115e452
Phase 8: WithTerminal end-to-end playground + plain-executable fix
mitchdenny May 5, 2026
c012f57
Phase 8 fix: TerminalView reacts to resource/replica parameter changes
mitchdenny May 5, 2026
46caa8d
WithTerminal: container support + Node.js playground
mitchdenny May 5, 2026
39a2ac0
Terminals playground: nodebox fix - override CMD instead of entrypoint
mitchdenny May 5, 2026
ff4f6de
Flip producer-side connection direction in Aspire.TerminalHost
mitchdenny May 5, 2026
a2b80f2
Dashboard terminal: send resize on WebSocket open before render
mitchdenny May 5, 2026
1685ab0
WithTerminal: process-restart support + Stop-regression hardening (Ph…
mitchdenny May 5, 2026
3799db3
Phase 11: CLI wires up HMP1 multi-head primary/secondary protocol
mitchdenny May 6, 2026
115b927
TerminalHost: bridge consumer-side resize to raw DCP HMP1 frame
mitchdenny May 8, 2026
7b6ffae
Update Hex1b to 0.147.0 + transient nuget.org Hex1b-only mapping
mitchdenny May 8, 2026
09aea3e
Phase 17 Dashboard: transplant WebMuxerDemo terminal chrome + dumb by…
mitchdenny May 8, 2026
2e27403
Phase 17 CLI: split TerminalCommand and lift WebMuxerDemo viewer expe…
mitchdenny May 8, 2026
71a7e5a
Terminals playground: shell2 control resource for Stop bisection + sl…
mitchdenny May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<Project Path="src/Aspire.Hosting.Valkey/Aspire.Hosting.Valkey.csproj" />
<Project Path="src/Aspire.Hosting.Yarp/Aspire.Hosting.Yarp.csproj" />
<Project Path="src/Aspire.Hosting/Aspire.Hosting.csproj" />
<Project Path="src/Aspire.TerminalHost/Aspire.TerminalHost.csproj" />
<Project Path="src/Aspire.TypeSystem/Aspire.TypeSystem.csproj" />
<Project Path="src/Aspire.ProjectTemplates/Aspire.ProjectTemplates.csproj" />
</Folder>
Expand Down Expand Up @@ -511,6 +512,7 @@
<Project Path="tests/Aspire.Hosting.SqlServer.Tests/Aspire.Hosting.SqlServer.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Testing.Tests/Aspire.Hosting.Testing.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Tests/Aspire.Hosting.Tests.csproj" />
<Project Path="tests/Aspire.TerminalHost.Tests/Aspire.TerminalHost.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Valkey.Tests/Aspire.Hosting.Valkey.Tests.csproj" />
<Project Path="tests/Aspire.Hosting.Yarp.Tests/Aspire.Hosting.Yarp.Tests.csproj" />
</Folder>
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<!-- TODO: Need to figure out we can automatically detect target framework here. This property
is specified to support dashboard path metadata generation on the inner loop. -->
<AspireDashboardDir>$(MSBuildThisFileDirectory)/artifacts/bin/Aspire.Dashboard/$(Configuration)/net8.0/</AspireDashboardDir>
<AspireTerminalHostDir>$(MSBuildThisFileDirectory)/artifacts/bin/Aspire.TerminalHost/$(Configuration)/net8.0/</AspireTerminalHostDir>
</PropertyGroup>

</Project>
6 changes: 3 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageVersion Include="Grpc.Net.ClientFactory" Version="2.76.0" />
<PackageVersion Include="Grpc.Tools" Version="2.78.0" />
<PackageVersion Include="Hex1b" Version="0.133.0" />
<PackageVersion Include="Hex1b.McpServer" Version="0.133.0" />
<PackageVersion Include="Hex1b.Tool" Version="0.133.0" />
<PackageVersion Include="Hex1b" Version="0.147.0" />
<PackageVersion Include="Hex1b.McpServer" Version="0.147.0" />
<PackageVersion Include="Hex1b.Tool" Version="0.147.0" />
<PackageVersion Include="Humanizer.Core" Version="2.14.1" />
<PackageVersion Include="KubernetesClient" Version="18.0.13" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
Expand Down
11 changes: 11 additions & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,22 @@
<add key="dotnet10" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet10/nuget/v3/index.json" />
<add key="dotnet-libraries" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-libraries/nuget/v3/index.json" />
<add key="dotnet9-transport" value="https://dnceng.pkgs.visualstudio.com/public/_packaging/dotnet9-transport/nuget/v3/index.json" />
<!--
TRANSIENT: nuget.org source scoped to Hex1b packages only via packageSourceMapping below.
Remove this source AND its mapping once the dotnet-public mirror has Hex1b 0.147.0+ available.
The wildcard mappings on dotnet-public/dotnet-eng MUST NOT be added to this source — Hex1b only.
-->
<add key="nuget-org-hex1b" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="dotnet9-transport">
<package pattern="*WorkloadBuildTasks*" />
</packageSource>
<!-- TRANSIENT: scope nuget.org to Hex1b only until dotnet-public mirror catches up. See note in packageSources above. -->
<packageSource key="nuget-org-hex1b">
<package pattern="Hex1b" />
<package pattern="Hex1b.*" />
</packageSource>
<packageSource key="dotnet-public">
<package pattern="*" />
<!-- FluentUI is present on both public an internal dotnet feeds. Prefer public feed -->
Expand Down
156 changes: 156 additions & 0 deletions docs/specs/with-terminal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# `WithTerminal()` — Aspire interactive terminal architecture

**Status:** Implemented for Aspire 13.4 (Windows executables).
**Issue:** [microsoft/aspire#16317](https://github.com/microsoft/aspire/issues/16317)
**DCP integration:** [microsoft/dcp#133](https://github.com/microsoft/dcp/pull/133)

## Goal

Let an Aspire AppHost author opt any executable or container resource into
interactive terminal access:

```csharp
builder.AddProject<Projects.MyAgent>("agent")
.WithReplicas(2)
.WithTerminal();
```

The dashboard then renders an xterm.js terminal per replica, and the CLI
exposes the same session as `aspire terminal agent --replica 0`.

## Process topology

```

Check failure on line 23 in docs/specs/with-terminal.md

View workflow job for this annotation

GitHub Actions / lint

Fenced code blocks should have a language specified [Context: "```"]
┌────────────────────────────┐
│ AppHost (dotnet run) │
│ - Aspire.Hosting │
│ - DCP control plane │
│ - per-resource: │
│ Aspire.TerminalHost │ (1 process / WithTerminal())
└─────────────┬──────────────┘
│ spawn
┌──────────────────────┐ ┌───────────────────────┐ ┌────────────────────────┐
│ DCP-launched │ PTY (Win) │ TerminalHost │ HMP v1 UDS │ Consumers │
│ replica process │ ─────────────▶ │ (Hex1b HMP v1 broker) │ ─────────────▶ │ - Dashboard │
│ (executable, repl…) │ ◀───── stdin ─ │ │ ◀───── input ─ │ /api/terminal proxy │
└──────────────────────┘ └───────────────────────┘ │ - aspire CLI │
└────────────────────────┘
```

Three actors and three socket roles:

| Actor | Socket | Direction | Lifetime |
|----------------|---------------------|----------------------------------|----------|
| **DCP** | `producerUdsPath` | DCP → host (PTY bytes + control) | Per replica |
| **TerminalHost** | `consumerUdsPath` | host → consumers (broadcast) | Per replica |
| **TerminalHost** | `controlUdsPath` | AppHost → host (lifecycle, stats)| Per resource |

The producer/consumer split lets multiple consumers (dashboard + multiple CLI
sessions) attach simultaneously without coupling DCP to consumer counts.

## Wire protocol

We do **not** define a custom protocol. The terminal traffic uses
[Hex1b](https://github.com/dotnet/hex1b)'s `HMP v1` (Hex Multiplex Protocol,
version 1), which already handles:

- VT byte streaming with backpressure
- Resize requests in both directions
- Hello/StateSync replay so a late-attaching consumer sees the current
scrollback
- Connection lifecycle (close, disconnect, reconnect)
- Authenticated stream factory hooks (we only use Unix-socket transport
today)

The `Hmp1WorkloadAdapter` is what the AppHost-side terminal host uses to
multiplex DCP's PTY traffic to the consumer-facing listener; the
`Hmp1PresentationAdapter` is what consumers (Dashboard WebSocket proxy and
the CLI) use to attach.

## Property contract (gRPC `ResourceService` snapshots)

When `WithTerminal()` is applied to a resource, every replica snapshot
emitted by the dashboard service carries four properties:

| Key | Sensitivity | Meaning |
|---------------------------|-----------------|----------------------------------------------|
| `terminal.enabled` | non-sensitive | Marker. `"true"` when the replica has a PTY. |
| `terminal.replicaIndex` | non-sensitive | 0-based stable index from `DcpInstancesAnnotation`. |
| `terminal.replicaCount` | non-sensitive | Total replicas for the parent resource. |
| `terminal.consumerUdsPath`| **sensitive** | The local UDS that consumers connect to. |

The consumer UDS path is marked `IsSensitive=true` so the dashboard UI masks
the value in the property list. The path still rides the gRPC stream because
the dashboard's WebSocket proxy needs it server-side to resolve
`?resource=&replica=` query parameters into a real socket; the path is never
echoed back to the browser.

## Dashboard `/api/terminal` WebSocket endpoint

Authenticated (`RequireAuthorization(FrontendAuthorizationDefaults.PolicyName)`)
endpoint at `/api/terminal?resource=<displayName>&replica=<index>`.

`TerminalWebSocketProxy` resolves the connection entirely server-side:

1. `ITerminalConnectionResolver.ConnectAsync(resourceName, replicaIndex, ct)`
walks `IDashboardClient.GetResources()`, matches by `DisplayName` +
`TryGetTerminalReplicaInfo`, and connects via
`Hmp1Transports.ConnectUnixSocket(consumerUdsPath, ct)`.
2. The proxy wraps the resulting stream in `Hmp1WorkloadAdapter` and runs
two pumps:
- **Inbound (browser → producer):** binary frames are forwarded as HMP v1
`Input` (keystrokes); text frames are parsed as JSON resize control
messages (`{"type":"resize","cols":N,"rows":N}`).
- **Outbound (producer → browser):** VT bytes from the producer become
binary WebSocket frames; resize hints from the producer become JSON
text frames.
3. Frame type — not content — distinguishes keystroke from control. This
keeps the proxy's parser cheap and avoids ambiguity around binary input
that happens to look like JSON.
4. Multi-fragment WS reads are reassembled in `ReassembledFrame` using
`ArrayPool<byte>`.

The browser never sees `consumerUdsPath` and cannot induce the dashboard
to connect to an arbitrary local socket — it can only ask for
`(resource, replica)` pairs that are present in the resource snapshot
stream.

## CLI

`aspire terminal <resource> [--replica N]` (`Aspire.Cli/Commands/TerminalCommand.cs`)
opens its own `Hmp1PresentationAdapter` against the consumer UDS path
returned by `IBackchannel.GetTerminalInfoAsync(resource, replica)` and
renders frames into the host terminal via Hex1b's `Hex1bTerminal`. When the
resource has more than one replica and the CLI is interactive, it prompts
for a selection; in non-interactive mode the `--replica` flag is required.

## DCP integration

DCP allocates a Windows ConPTY per replica when the spec carries
`terminal.enabled=true` and the supplied `terminal.producerUdsPath`,
`terminal.consumerUdsPath`, and `terminal.controlUdsPath` properties. DCP
opens the producer-side UDS and writes PTY traffic into it; the
TerminalHost owns the consumer-side listener and broadcasts to attached
clients.

Linux / macOS / container PTY support is tracked as Phase 3 follow-ups in
the parent issue; for the 13.4 release, the playground guards
non-Windows code paths with `OperatingSystem.IsWindows()` checks.

## Files of interest

| Concern | File |
|--------------------------------------|---------------------------------------------------------------------|
| Public API entry point | `src/Aspire.Hosting/TerminalResourceBuilderExtensions.cs` |
| Per-resource hidden host resource | `src/Aspire.Hosting/ApplicationModel/TerminalHostResource.cs` |
| DCP wire-up | `src/Aspire.Hosting/Dcp/ExecutableCreator.cs` |
| Backchannel `GetTerminalInfoAsync` | `src/Aspire.Hosting/Backchannel/AuxiliaryBackchannelRpcTarget.cs` |
| Snapshot stamping | `src/Aspire.Hosting/Dashboard/DashboardServiceData.cs` |
| TerminalHost process | `src/Aspire.TerminalHost/` |
| CLI command | `src/Aspire.Cli/Commands/TerminalCommand.cs` |
| Dashboard WebSocket proxy | `src/Aspire.Dashboard/Terminal/TerminalWebSocketProxy.cs` |
| Dashboard resolver | `src/Aspire.Dashboard/Terminal/DefaultTerminalConnectionResolver.cs`|
| `TerminalView` (xterm.js host) | `src/Aspire.Dashboard/Components/Controls/TerminalView.razor.*` |
| Property keys | `src/Shared/Model/KnownProperties.cs` (`Terminal.*`) |
| Playground sample | `playground/Terminals/Terminals.AppHost/AppHost.cs` |
61 changes: 61 additions & 0 deletions playground/Terminals/Terminals.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

var builder = DistributedApplication.CreateBuilder(args);

// A multi-replica project that calls `WithTerminal()` so each replica gets its
// own pseudo-terminal and the dashboard can attach to any of them via
// `/api/terminal?resource=repl&replica=<i>`. The replica index is forwarded as
// an environment variable so the REPL can stamp it on its banner.
builder.AddProject<Projects.Terminals_Repl>("repl")
.WithReplicas(2)
.WithEnvironment("ASPIRE_RESOURCE_NAME", "repl")
.WithTerminal(options =>
{
options.Columns = 120;
options.Rows = 32;
});

if (OperatingSystem.IsWindows())
{
// Single-replica executable wrapping cmd.exe to demonstrate that
// WithTerminal() also works for arbitrary executables, not just projects.
// DCP's PTY allocator currently only supports Windows, so this branch is
// gated behind an OS check; future Phase 3 follow-ups will extend it to
// Linux and macOS.
builder.AddExecutable("shell", "cmd.exe", ".")
.WithTerminal();

// A/B control resource: same cmd.exe but WITHOUT WithTerminal(). Used to
// bisect whether the "Stop kills the dashboard" symptom is specific to
// PTY-attached resources or applies to any DCP-managed Windows process.
// Without a PTY, cmd.exe with redirected stdin would exit immediately, so
// we wrap it in `/c ping -t 127.0.0.1` (continuous ping to localhost
// until killed) so the resource stays in the Running state long enough
// for "Stop" to be clicked from the dashboard.
builder.AddExecutable("shell2", "cmd.exe", ".", "/c", "ping -t 127.0.0.1 > NUL");

// Container resource exercising the container-side WithTerminal() path.
// Launches a long-running Node.js LTS image with an interactive bash so
// the terminal attaches to a shell where `npx`, `node`, and friends are
// available. We override the image CMD (not the entrypoint!) so the
// image's docker-entrypoint.sh keeps running and exec's bash for us;
// overriding the entrypoint instead would leave the image's CMD
// ("node") appended after bash and immediately exit.
builder.AddContainer("nodebox", "node", "lts")
.WithArgs("bash", "-l")
.WithTerminal();
}

#if !SKIP_DASHBOARD_REFERENCE
// This project is only added in playground projects to support development/debugging
// of the dashboard. It is not required in end developer code. Comment out this code
// or build with `/p:SkipDashboardReference=true`, to test end developer
// dashboard launch experience, Refer to Directory.Build.props for the path to
// the dashboard binary (defaults to the Aspire.Dashboard bin output in the
// artifacts dir).
builder.AddProject<Projects.Aspire_Dashboard>(KnownResourceNames.AspireDashboard);
#endif

builder.Build().Run();

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:16180;http://localhost:17120",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17119",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17119",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:16181",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17120",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17120",
"ASPIRE_SHOW_DASHBOARD_RESOURCES": "true",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
}
}
}
23 changes: 23 additions & 0 deletions playground/Terminals/Terminals.AppHost/Terminals.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireHost>true</IsAspireHost>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\..\KnownResourceNames.cs" Link="KnownResourceNames.cs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Terminals.Repl\Terminals.Repl.csproj" IsAspireProjectResource="true" />
</ItemGroup>

<ItemGroup>
<AspireProjectOrPackageReference Include="Aspire.Hosting.AppHost" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
8 changes: 8 additions & 0 deletions playground/Terminals/Terminals.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
Loading
Loading