Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6adade7
Add WithTerminal API: TerminalAnnotation, TerminalHostResource, and e…
mitchdenny Apr 20, 2026
250f16b
Add Aspire Terminal Protocol spec, shared codec, and playground
mitchdenny Apr 20, 2026
0025cd1
Add terminal protocol test client and verify end-to-end
mitchdenny Apr 20, 2026
4715311
Add DCP model TerminalSpec, backchannel RPC, and CLI terminal command
mitchdenny Apr 20, 2026
b93f7e8
Add WithTerminal custom socket path provider overload
mitchdenny Apr 20, 2026
cb68325
Add Dashboard terminal support: xterm.js, WebSocket proxy, ConsoleLog…
mitchdenny Apr 20, 2026
251a5cf
Fix Dashboard terminal: use script tags for xterm.js and int IDs for …
mitchdenny Apr 20, 2026
d438a0f
Add production Aspire.TerminalHost and wire into AppHost discovery
mitchdenny Apr 20, 2026
43241dc
Fix playground: use custom socket path overload to avoid dual termina…
mitchdenny Apr 20, 2026
9a0629a
Redesign terminal host for reconnection with state replay
mitchdenny Apr 20, 2026
a62f2aa
WithTerminal Phase 1: refactor to per-replica UDS pair design
mitchdenny May 4, 2026
d309bee
WithTerminal Phase 2: Aspire.TerminalHost on Hex1b HMP v1
mitchdenny May 4, 2026
a73d195
WithTerminal Phase 4: wire per-replica TerminalSpec into DCP Executab…
mitchdenny May 4, 2026
05e6cf3
WithTerminal Phase 5: backchannel exposes per-replica terminal endpoints
mitchdenny May 5, 2026
d0a6664
WithTerminal Phase 6: aspire terminal CLI command on Hex1b HMP v1
mitchdenny May 5, 2026
16339c5
Phase 7: Dashboard /api/terminal WebSocket proxy + per-replica Termin…
mitchdenny May 5, 2026
e5535e9
Phase 8: WithTerminal end-to-end playground + plain-executable fix
mitchdenny May 5, 2026
e8e6aef
Phase 8 fix: TerminalView reacts to resource/replica parameter changes
mitchdenny May 5, 2026
d7ecb7b
WithTerminal: container support + Node.js playground
mitchdenny May 5, 2026
3f5491c
Terminals playground: nodebox fix - override CMD instead of entrypoint
mitchdenny May 5, 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 @@ -78,6 +78,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 @@ -507,6 +508,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 @@ -99,9 +99,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.126.0" />
<PackageVersion Include="Hex1b.McpServer" Version="0.126.0" />
<PackageVersion Include="Hex1b.Tool" Version="0.126.0" />
<PackageVersion Include="Hex1b" Version="0.137.0" />
<PackageVersion Include="Hex1b.McpServer" Version="0.137.0" />
<PackageVersion Include="Hex1b.Tool" Version="0.137.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
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

```
┌────────────────────────────┐
│ 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` |
52 changes: 52 additions & 0 deletions playground/Terminals/Terminals.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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();

// 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