Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Adapters/PtyAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Text.Json.Serialization;
using OpenSandbox.Internal;
using OpenSandbox.Models;
using OpenSandbox.Services;

namespace OpenSandbox.Adapters;

/// <summary>
/// Adapter for the execd interactive PTY session service.
/// </summary>
internal sealed class PtyAdapter : IExecdPty
{
private readonly HttpClientWrapper _client;

public PtyAdapter(HttpClientWrapper client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}

public Task<PtySession> CreateSessionAsync(
string? cwd = null,
string? command = null,
CancellationToken cancellationToken = default)
{
var body = new PtyCreateRequest { Cwd = cwd, Command = command };
return _client.PostAsync<PtySession>("/pty", body, cancellationToken);
}

public Task<PtySessionStatus> GetSessionAsync(string sessionId, CancellationToken cancellationToken = default)
{
return _client.GetAsync<PtySessionStatus>(
$"/pty/{Uri.EscapeDataString(sessionId)}",
cancellationToken: cancellationToken);
}

public Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default)
{
return _client.DeleteAsync(
$"/pty/{Uri.EscapeDataString(sessionId)}",
cancellationToken: cancellationToken);
}

private sealed class PtyCreateRequest
{
[JsonPropertyName("cwd")]
public string? Cwd { get; set; }

[JsonPropertyName("command")]
public string? Command { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options)

var health = new HealthAdapter(clientWrapper);
var metrics = new MetricsAdapter(clientWrapper);
var pty = new PtyAdapter(clientWrapper);
var files = new FilesystemAdapter(
clientWrapper,
options.HttpClientProvider.HttpClient,
Expand All @@ -76,7 +77,8 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options)
Commands = commands,
Files = files,
Health = health,
Metrics = metrics
Metrics = metrics,
Pty = pty
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ public class ExecdStack
/// Gets the metrics service.
/// </summary>
public required IExecdMetrics Metrics { get; init; }

/// <summary>
/// Gets the interactive PTY session service.
/// </summary>
/// <remarks>
/// Optional for backward compatibility: factories created before PTY support may leave this
/// null, in which case <see cref="Sandbox"/> installs an unavailable-PTY fallback.
/// </remarks>
public IExecdPty? Pty { get; init; }
}

public class CreateEgressStackOptions
Expand Down
54 changes: 54 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Models/Pty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Text.Json.Serialization;

namespace OpenSandbox.Models;

/// <summary>
/// A created PTY session. The shell starts on the first WebSocket attach.
/// </summary>
public sealed class PtySession
{
/// <summary>
/// Gets or sets the server-assigned identifier of the PTY session.
/// </summary>
[JsonPropertyName("session_id")]
public required string SessionId { get; set; }
}

/// <summary>
/// Current status of a PTY session.
/// </summary>
public sealed class PtySessionStatus
{
/// <summary>
/// Gets or sets the identifier of the PTY session.
/// </summary>
[JsonPropertyName("session_id")]
public required string SessionId { get; set; }

/// <summary>
/// Gets or sets whether the underlying shell process is alive.
/// </summary>
[JsonPropertyName("running")]
public bool Running { get; set; }

/// <summary>
/// Gets or sets the byte offset of buffered output; pass it as <c>since</c>
/// on reconnect to replay scrollback from that point.
/// </summary>
[JsonPropertyName("output_offset")]
public long OutputOffset { get; set; }
}
31 changes: 31 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ public sealed class Sandbox : IAsyncDisposable
/// </summary>
public IExecdMetrics Metrics { get; }

/// <summary>
/// Gets the interactive PTY (pseudo-terminal) session service.
/// </summary>
public IExecdPty Pty { get; }

/// <summary>
/// Gets the sandbox-scoped Credential Vault service.
/// </summary>
Expand Down Expand Up @@ -96,6 +101,7 @@ private Sandbox(
ISandboxFiles files,
IExecdHealth health,
IExecdMetrics metrics,
IExecdPty? pty,
IEgress egress,
ICredentialVault? credentialVault)
{
Expand All @@ -112,6 +118,7 @@ private Sandbox(
Files = files;
Health = health;
Metrics = metrics;
Pty = pty ?? new UnavailablePtyService();
_egress = egress;
CredentialVault = credentialVault
?? egress as ICredentialVault
Expand Down Expand Up @@ -255,6 +262,7 @@ public static async Task<Sandbox> CreateAsync(
execdStack.Files,
execdStack.Health,
execdStack.Metrics,
execdStack.Pty,
egressStack.Egress,
egressStack.CredentialVault);

Expand Down Expand Up @@ -380,6 +388,7 @@ public static async Task<Sandbox> ConnectAsync(
execdStack.Files,
execdStack.Health,
execdStack.Metrics,
execdStack.Pty,
egressStack.Egress,
egressStack.CredentialVault);

Expand Down Expand Up @@ -909,4 +918,26 @@ public Task<CredentialBindingMetadata> GetBindingAsync(

private static InvalidArgumentException CreateException() => new(Message);
}

private sealed class UnavailablePtyService : IExecdPty
{
private const string Message =
"PTY service is not available for this adapter factory. Provide ExecdStack.Pty to use PTY with a custom adapter.";

public Task<PtySession> CreateSessionAsync(
string? cwd = null,
string? command = null,
CancellationToken cancellationToken = default) =>
Task.FromException<PtySession>(CreateException());

public Task<PtySessionStatus> GetSessionAsync(
string sessionId,
CancellationToken cancellationToken = default) =>
Task.FromException<PtySessionStatus>(CreateException());

public Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) =>
Task.FromException(CreateException());

private static InvalidArgumentException CreateException() => new(Message);
}
}
59 changes: 59 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdPty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using OpenSandbox.Core;
using OpenSandbox.Models;

namespace OpenSandbox.Services;

/// <summary>
/// Service interface for interactive PTY (pseudo-terminal) session lifecycle on the execd service.
/// </summary>
/// <remarks>
/// Manages the session lifecycle over execd's REST API (create / status / delete). Attaching to the
/// interactive <c>/pty/{sessionId}/ws</c> WebSocket stream is a separate concern. PTY is only
/// supported on Unix-like platforms.
/// </remarks>
public interface IExecdPty
{
/// <summary>
/// Creates a new PTY session. The shell starts on the first WebSocket attach.
/// </summary>
/// <param name="cwd">Optional working directory for the shell.</param>
/// <param name="command">Optional command to run instead of the default login shell.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created session.</returns>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<PtySession> CreateSessionAsync(
string? cwd = null,
string? command = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Retrieves the current status of a PTY session.
/// </summary>
/// <param name="sessionId">Identifier of the PTY session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session status, including the output offset usable for replay.</returns>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<PtySessionStatus> GetSessionAsync(string sessionId, CancellationToken cancellationToken = default);

/// <summary>
/// Tears down a PTY session on the server side.
/// </summary>
/// <param name="sessionId">Identifier of the PTY session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default);
}
102 changes: 102 additions & 0 deletions sdks/sandbox/csharp/tests/OpenSandbox.Tests/PtyAdapterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 Alibaba Group Holding Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Net;
using System.Text;
using FluentAssertions;
using OpenSandbox.Adapters;
using OpenSandbox.Internal;
using Xunit;

namespace OpenSandbox.Tests;

public class PtyAdapterTests
{
[Fact]
public async Task CreateSessionAsync_ShouldPostAndParseSessionId()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
request.Method.Should().Be(HttpMethod.Post);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
{
Content = new StringContent("{\"session_id\":\"sess-123\"}", Encoding.UTF8, "application/json")
});
});

var session = await CreateAdapter(handler).CreateSessionAsync("/tmp", "bash");

session.SessionId.Should().Be("sess-123");
handler.RequestUris.Should().Contain(uri => uri.EndsWith("/pty"));
}

[Fact]
public async Task GetSessionAsync_ShouldParseStatus()
{
var handler = new StubHttpMessageHandler((_, _) =>
{
var body = "{\"session_id\":\"sess-123\",\"running\":true,\"output_offset\":4096}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
});
});

var status = await CreateAdapter(handler).GetSessionAsync("sess-123");

status.SessionId.Should().Be("sess-123");
status.Running.Should().BeTrue();
status.OutputOffset.Should().Be(4096);
handler.RequestUris.Should().Contain(uri => uri.EndsWith("/pty/sess-123"));
}

[Fact]
public async Task DeleteSessionAsync_ShouldIssueDelete()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
request.Method.Should().Be(HttpMethod.Delete);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
});

await CreateAdapter(handler).DeleteSessionAsync("sess-123");

handler.RequestUris.Should().Contain(uri => uri.EndsWith("/pty/sess-123"));
}

private static PtyAdapter CreateAdapter(HttpMessageHandler handler)
{
var headers = new Dictionary<string, string>();
var client = new HttpClientWrapper(new HttpClient(handler), "http://execd.local", headers);
return new PtyAdapter(client);
}

private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;

public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}

public List<string> RequestUris { get; } = new();

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestUris.Add(request.RequestUri?.ToString() ?? string.Empty);
return await _handler(request, cancellationToken).ConfigureAwait(false);
}
}
}
Loading
Loading