diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/PtyAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/PtyAdapter.cs
new file mode 100644
index 000000000..2507bfc7a
--- /dev/null
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/PtyAdapter.cs
@@ -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;
+
+///
+/// Adapter for the execd interactive PTY session service.
+///
+internal sealed class PtyAdapter : IExecdPty
+{
+ private readonly HttpClientWrapper _client;
+
+ public PtyAdapter(HttpClientWrapper client)
+ {
+ _client = client ?? throw new ArgumentNullException(nameof(client));
+ }
+
+ public Task CreateSessionAsync(
+ string? cwd = null,
+ string? command = null,
+ CancellationToken cancellationToken = default)
+ {
+ var body = new PtyCreateRequest { Cwd = cwd, Command = command };
+ return _client.PostAsync("/pty", body, cancellationToken);
+ }
+
+ public Task GetSessionAsync(string sessionId, CancellationToken cancellationToken = default)
+ {
+ return _client.GetAsync(
+ $"/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; }
+ }
+}
diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs
index f3c04b0a8..4cfa12812 100644
--- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/DefaultAdapterFactory.cs
@@ -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,
@@ -76,7 +77,8 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options)
Commands = commands,
Files = files,
Health = health,
- Metrics = metrics
+ Metrics = metrics,
+ Pty = pty
};
}
diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs
index 9112bbae3..c7d1bec28 100644
--- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs
@@ -112,6 +112,15 @@ public class ExecdStack
/// Gets the metrics service.
///
public required IExecdMetrics Metrics { get; init; }
+
+ ///
+ /// Gets the interactive PTY session service.
+ ///
+ ///
+ /// Optional for backward compatibility: factories created before PTY support may leave this
+ /// null, in which case installs an unavailable-PTY fallback.
+ ///
+ public IExecdPty? Pty { get; init; }
}
public class CreateEgressStackOptions
diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Models/Pty.cs b/sdks/sandbox/csharp/src/OpenSandbox/Models/Pty.cs
new file mode 100644
index 000000000..67c7a366b
--- /dev/null
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Pty.cs
@@ -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;
+
+///
+/// A created PTY session. The shell starts on the first WebSocket attach.
+///
+public sealed class PtySession
+{
+ ///
+ /// Gets or sets the server-assigned identifier of the PTY session.
+ ///
+ [JsonPropertyName("session_id")]
+ public required string SessionId { get; set; }
+}
+
+///
+/// Current status of a PTY session.
+///
+public sealed class PtySessionStatus
+{
+ ///
+ /// Gets or sets the identifier of the PTY session.
+ ///
+ [JsonPropertyName("session_id")]
+ public required string SessionId { get; set; }
+
+ ///
+ /// Gets or sets whether the underlying shell process is alive.
+ ///
+ [JsonPropertyName("running")]
+ public bool Running { get; set; }
+
+ ///
+ /// Gets or sets the byte offset of buffered output; pass it as since
+ /// on reconnect to replay scrollback from that point.
+ ///
+ [JsonPropertyName("output_offset")]
+ public long OutputOffset { get; set; }
+}
diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs
index 13d779b45..3aa47c767 100644
--- a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs
@@ -64,6 +64,11 @@ public sealed class Sandbox : IAsyncDisposable
///
public IExecdMetrics Metrics { get; }
+ ///
+ /// Gets the interactive PTY (pseudo-terminal) session service.
+ ///
+ public IExecdPty Pty { get; }
+
///
/// Gets the sandbox-scoped Credential Vault service.
///
@@ -96,6 +101,7 @@ private Sandbox(
ISandboxFiles files,
IExecdHealth health,
IExecdMetrics metrics,
+ IExecdPty? pty,
IEgress egress,
ICredentialVault? credentialVault)
{
@@ -112,6 +118,7 @@ private Sandbox(
Files = files;
Health = health;
Metrics = metrics;
+ Pty = pty ?? new UnavailablePtyService();
_egress = egress;
CredentialVault = credentialVault
?? egress as ICredentialVault
@@ -255,6 +262,7 @@ public static async Task CreateAsync(
execdStack.Files,
execdStack.Health,
execdStack.Metrics,
+ execdStack.Pty,
egressStack.Egress,
egressStack.CredentialVault);
@@ -380,6 +388,7 @@ public static async Task ConnectAsync(
execdStack.Files,
execdStack.Health,
execdStack.Metrics,
+ execdStack.Pty,
egressStack.Egress,
egressStack.CredentialVault);
@@ -909,4 +918,26 @@ public Task 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 CreateSessionAsync(
+ string? cwd = null,
+ string? command = null,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(CreateException());
+
+ public Task GetSessionAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromException(CreateException());
+
+ public Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default) =>
+ Task.FromException(CreateException());
+
+ private static InvalidArgumentException CreateException() => new(Message);
+ }
}
diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdPty.cs b/sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdPty.cs
new file mode 100644
index 000000000..8fbc4850d
--- /dev/null
+++ b/sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdPty.cs
@@ -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;
+
+///
+/// Service interface for interactive PTY (pseudo-terminal) session lifecycle on the execd service.
+///
+///
+/// Manages the session lifecycle over execd's REST API (create / status / delete). Attaching to the
+/// interactive /pty/{sessionId}/ws WebSocket stream is a separate concern. PTY is only
+/// supported on Unix-like platforms.
+///
+public interface IExecdPty
+{
+ ///
+ /// Creates a new PTY session. The shell starts on the first WebSocket attach.
+ ///
+ /// Optional working directory for the shell.
+ /// Optional command to run instead of the default login shell.
+ /// Cancellation token.
+ /// The created session.
+ /// Thrown when the execd service request fails.
+ Task CreateSessionAsync(
+ string? cwd = null,
+ string? command = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Retrieves the current status of a PTY session.
+ ///
+ /// Identifier of the PTY session.
+ /// Cancellation token.
+ /// Session status, including the output offset usable for replay.
+ /// Thrown when the execd service request fails.
+ Task GetSessionAsync(string sessionId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Tears down a PTY session on the server side.
+ ///
+ /// Identifier of the PTY session.
+ /// Cancellation token.
+ /// Thrown when the execd service request fails.
+ Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default);
+}
diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/PtyAdapterTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/PtyAdapterTests.cs
new file mode 100644
index 000000000..597bae93e
--- /dev/null
+++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/PtyAdapterTests.cs
@@ -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();
+ var client = new HttpClientWrapper(new HttpClient(handler), "http://execd.local", headers);
+ return new PtyAdapter(client);
+ }
+
+ private sealed class StubHttpMessageHandler : HttpMessageHandler
+ {
+ private readonly Func> _handler;
+
+ public StubHttpMessageHandler(Func> handler)
+ {
+ _handler = handler;
+ }
+
+ public List RequestUris { get; } = new();
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ RequestUris.Add(request.RequestUri?.ToString() ?? string.Empty);
+ return await _handler(request, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/sdks/sandbox/go/sandbox_pty.go b/sdks/sandbox/go/sandbox_pty.go
new file mode 100644
index 000000000..3d7038340
--- /dev/null
+++ b/sdks/sandbox/go/sandbox_pty.go
@@ -0,0 +1,94 @@
+// Copyright 2025 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.
+
+package opensandbox
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+)
+
+// PtyCreateRequest is the optional body for creating a PTY session.
+type PtyCreateRequest struct {
+ // Cwd is the working directory for the shell.
+ Cwd string `json:"cwd,omitempty"`
+ // Command runs instead of the default login shell when set.
+ Command string `json:"command,omitempty"`
+}
+
+// PtySession identifies a created PTY session. The shell starts on the first
+// WebSocket attach, not at creation time.
+type PtySession struct {
+ SessionID string `json:"session_id"`
+}
+
+// PtySessionStatus is the status of a PTY session.
+type PtySessionStatus struct {
+ SessionID string `json:"session_id"`
+ Running bool `json:"running"`
+ // OutputOffset is the byte offset of buffered output; pass it as `since`
+ // on reconnect to replay scrollback from that point.
+ OutputOffset int64 `json:"output_offset"`
+}
+
+// CreatePtySession creates a new interactive PTY session.
+func (e *ExecdClient) CreatePtySession(ctx context.Context, req PtyCreateRequest) (*PtySession, error) {
+ var result PtySession
+ if err := e.client.doRequest(ctx, http.MethodPost, "/pty", req, &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetPtySession returns the status of a PTY session.
+func (e *ExecdClient) GetPtySession(ctx context.Context, sessionID string) (*PtySessionStatus, error) {
+ var result PtySessionStatus
+ path := "/pty/" + url.PathEscape(sessionID)
+ if err := e.client.doRequest(ctx, http.MethodGet, path, nil, &result); err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// DeletePtySession tears down a PTY session on the server side.
+func (e *ExecdClient) DeletePtySession(ctx context.Context, sessionID string) error {
+ return e.client.doRequest(ctx, http.MethodDelete, "/pty/"+url.PathEscape(sessionID), nil, nil)
+}
+
+// CreatePtySession creates a new interactive PTY session on the sandbox. The
+// interactive stream itself is a WebSocket and is driven separately.
+func (s *Sandbox) CreatePtySession(ctx context.Context, req PtyCreateRequest) (*PtySession, error) {
+ if s.execd == nil {
+ return nil, fmt.Errorf("opensandbox: execd client not initialized")
+ }
+ return s.execd.CreatePtySession(ctx, req)
+}
+
+// GetPtySession returns the status of a PTY session on the sandbox.
+func (s *Sandbox) GetPtySession(ctx context.Context, sessionID string) (*PtySessionStatus, error) {
+ if s.execd == nil {
+ return nil, fmt.Errorf("opensandbox: execd client not initialized")
+ }
+ return s.execd.GetPtySession(ctx, sessionID)
+}
+
+// DeletePtySession tears down a PTY session on the sandbox.
+func (s *Sandbox) DeletePtySession(ctx context.Context, sessionID string) error {
+ if s.execd == nil {
+ return fmt.Errorf("opensandbox: execd client not initialized")
+ }
+ return s.execd.DeletePtySession(ctx, sessionID)
+}
diff --git a/sdks/sandbox/go/sandbox_pty_test.go b/sdks/sandbox/go/sandbox_pty_test.go
new file mode 100644
index 000000000..71e7d577c
--- /dev/null
+++ b/sdks/sandbox/go/sandbox_pty_test.go
@@ -0,0 +1,66 @@
+// Copyright 2025 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.
+
+package opensandbox
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSandbox_PtyLifecycle(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/pty":
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(map[string]any{"session_id": "sess-123"})
+ case r.Method == http.MethodGet && r.URL.Path == "/pty/sess-123":
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "session_id": "sess-123",
+ "running": true,
+ "output_offset": 4096,
+ })
+ case r.Method == http.MethodDelete && r.URL.Path == "/pty/sess-123":
+ w.WriteHeader(http.StatusOK)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ defer srv.Close()
+
+ sb := &Sandbox{id: "sbx-pty", execd: NewExecdClient(srv.URL, "tok")}
+ ctx := context.Background()
+
+ sess, err := sb.CreatePtySession(ctx, PtyCreateRequest{Cwd: "/tmp", Command: "bash"})
+ require.NoError(t, err)
+ require.Equal(t, "sess-123", sess.SessionID)
+
+ status, err := sb.GetPtySession(ctx, "sess-123")
+ require.NoError(t, err)
+ require.Equal(t, "sess-123", status.SessionID)
+ require.True(t, status.Running)
+ require.Equal(t, int64(4096), status.OutputOffset)
+
+ require.NoError(t, sb.DeletePtySession(ctx, "sess-123"))
+}
+
+func TestSandbox_Pty_ExecdNil(t *testing.T) {
+ sb := &Sandbox{id: "no-execd"}
+ _, err := sb.CreatePtySession(context.Background(), PtyCreateRequest{})
+ require.Error(t, err)
+}
diff --git a/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts
new file mode 100644
index 000000000..6964f7946
--- /dev/null
+++ b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts
@@ -0,0 +1,80 @@
+// 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.
+
+import type { ExecdClient } from "../openapi/execdClient.js";
+import { throwOnOpenApiFetchError } from "./openapiError.js";
+import type { PtySession, PtySessionStatus } from "../models/execd.js";
+import type { ExecdPty } from "../services/execdPty.js";
+import { SandboxError, SandboxException } from "../core/exceptions.js";
+
+export class PtyAdapter implements ExecdPty {
+ constructor(private readonly client: ExecdClient) {}
+
+ async createSession(opts?: { cwd?: string; command?: string }): Promise {
+ const { data, error, response } = await this.client.POST("/pty", {
+ body: { cwd: opts?.cwd, command: opts?.command },
+ });
+ throwOnOpenApiFetchError({ error, response }, "Create PTY session failed");
+ return { sessionId: data!.session_id };
+ }
+
+ async getSession(sessionId: string): Promise {
+ const { data, error, response } = await this.client.GET("/pty/{sessionId}", {
+ params: { path: { sessionId } },
+ });
+ throwOnOpenApiFetchError({ error, response }, "Get PTY session failed");
+ return {
+ sessionId: data!.session_id,
+ running: data!.running,
+ outputOffset: data!.output_offset,
+ };
+ }
+
+ async deleteSession(sessionId: string): Promise {
+ // Success is an empty 200 body (Content-Length: 0), which openapi-fetch skips
+ // parsing; error responses (e.g. 404 CONTEXT_NOT_FOUND, 501 NOT_SUPPORTED) keep
+ // their JSON code/message so throwOnOpenApiFetchError can surface them.
+ const { error, response } = await this.client.DELETE("/pty/{sessionId}", {
+ params: { path: { sessionId } },
+ });
+ throwOnOpenApiFetchError({ error, response }, "Delete PTY session failed");
+ }
+}
+
+/**
+ * Fallback PTY service used when a custom {@link AdapterFactory} does not supply a
+ * PTY adapter. Keeps `sandbox.pty` defined while failing loudly on use, so the
+ * execd stack contract stays additive for pre-existing factories.
+ */
+export class UnavailablePtyAdapter implements ExecdPty {
+ private failure(): SandboxException {
+ return new SandboxException({
+ message:
+ "PTY service is not available: the configured adapter factory did not provide a PTY adapter.",
+ error: new SandboxError(SandboxError.INVALID_ARGUMENT, "PTY service unavailable"),
+ });
+ }
+
+ createSession(): Promise {
+ return Promise.reject(this.failure());
+ }
+
+ getSession(): Promise {
+ return Promise.reject(this.failure());
+ }
+
+ deleteSession(): Promise {
+ return Promise.reject(this.failure());
+ }
+}
diff --git a/sdks/sandbox/javascript/src/api/execd.ts b/sdks/sandbox/javascript/src/api/execd.ts
index af5ab7101..041a8e400 100644
--- a/sdks/sandbox/javascript/src/api/execd.ts
+++ b/sdks/sandbox/javascript/src/api/execd.ts
@@ -484,6 +484,17 @@ export interface paths {
* only immediate children are returned (`depth=1`). Set `depth` to a larger
* value to include descendants up to that many levels below `path`. The
* root directory itself is not included in the response.
+ *
+ * Symbolic links are reported with `type=symlink` and are not traversed:
+ * the listing never descends into a link target, even when `depth` would
+ * otherwise allow it. For the same reason, when `path` itself resolves to
+ * a symbolic link the request is rejected with `400`; callers must pass
+ * the real directory path they want listed.
+ *
+ * Entries are returned in lexical order by entry name within each
+ * directory. Descendants reported via `depth>1` follow their parent in
+ * the same lexical order, so a depth-2 listing yields stable, predictable
+ * output for file-browser style clients.
*/
get: operations["listDirectory"];
put?: never;
@@ -566,10 +577,88 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/pty": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Create PTY session (create_pty_session)
+ * @description Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ * not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ * channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+ */
+ post: operations["createPtySession"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/pty/{sessionId}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get PTY session status (get_pty_session)
+ * @description Returns the status of a PTY session, including the output offset usable for replay.
+ */
+ get: operations["getPtySession"];
+ put?: never;
+ post?: never;
+ /**
+ * Delete PTY session (delete_pty_session)
+ * @description Tears down a PTY session on the server side, terminating the underlying shell process.
+ * Returns 200 on success (the execd controller responds with an empty success body).
+ */
+ delete: operations["deletePtySession"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
schemas: {
+ /** @description Request to create a PTY session (optional body; empty treated as defaults) */
+ CreatePtySessionRequest: {
+ /**
+ * @description Working directory for the shell
+ * @example /workspace
+ */
+ cwd?: string;
+ /** @description Command to run instead of the default login shell */
+ command?: string;
+ };
+ CreatePtySessionResponse: {
+ /**
+ * @description Server-assigned identifier of the PTY session
+ * @example pty-abc123
+ */
+ session_id: string;
+ };
+ PtySessionStatusResponse: {
+ /**
+ * @description Identifier of the PTY session
+ * @example pty-abc123
+ */
+ session_id: string;
+ /** @description Whether the underlying shell process is alive */
+ running: boolean;
+ /**
+ * Format: int64
+ * @description Byte offset of buffered output; pass as `since` on reconnect to replay scrollback
+ */
+ output_offset: number;
+ };
/** @description Request to create a bash session (optional body; empty treated as defaults) */
CreateSessionRequest: {
/**
@@ -1003,6 +1092,21 @@ export interface components {
"application/json": components["schemas"]["ErrorResponse"];
};
};
+ /** @description Operation not supported on this platform (e.g. PTY on Windows) */
+ NotImplemented: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ /**
+ * @example {
+ * "code": "NOT_SUPPORTED",
+ * "message": "PTY is not supported on this platform"
+ * }
+ */
+ "application/json": components["schemas"]["ErrorResponse"];
+ };
+ };
};
parameters: never;
requestBodies: never;
@@ -1907,4 +2011,87 @@ export interface operations {
500: components["responses"]["InternalServerError"];
};
};
+ createPtySession: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": components["schemas"]["CreatePtySessionRequest"];
+ };
+ };
+ responses: {
+ /** @description PTY session created successfully */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreatePtySessionResponse"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 500: components["responses"]["InternalServerError"];
+ 501: components["responses"]["NotImplemented"];
+ };
+ };
+ getPtySession: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /**
+ * @description Session ID returned by create_pty_session
+ * @example pty-abc123
+ */
+ sessionId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description PTY session status */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PtySessionStatusResponse"];
+ };
+ };
+ 404: components["responses"]["NotFound"];
+ 500: components["responses"]["InternalServerError"];
+ 501: components["responses"]["NotImplemented"];
+ };
+ };
+ deletePtySession: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /**
+ * @description Session ID to delete
+ * @example pty-abc123
+ */
+ sessionId: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description PTY session deleted successfully */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: components["responses"]["NotFound"];
+ 500: components["responses"]["InternalServerError"];
+ 501: components["responses"]["NotImplemented"];
+ };
+ };
}
diff --git a/sdks/sandbox/javascript/src/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts
index 0dd9e935f..b6bced15f 100644
--- a/sdks/sandbox/javascript/src/factory/adapterFactory.ts
+++ b/sdks/sandbox/javascript/src/factory/adapterFactory.ts
@@ -18,6 +18,7 @@ import type { CredentialVault, Egress } from "../services/egress.js";
import type { ExecdCommands } from "../services/execdCommands.js";
import type { ExecdHealth } from "../services/execdHealth.js";
import type { ExecdMetrics } from "../services/execdMetrics.js";
+import type { ExecdPty } from "../services/execdPty.js";
import type { Sandboxes } from "../services/sandboxes.js";
export interface CreateLifecycleStackOptions {
@@ -40,6 +41,11 @@ export interface ExecdStack {
files: SandboxFiles;
health: ExecdHealth;
metrics: ExecdMetrics;
+ /**
+ * Optional for backward compatibility: factories created before PTY support may
+ * omit this. {@link Sandbox} installs an unavailable-PTY fallback when absent.
+ */
+ pty?: ExecdPty;
}
export interface CreateEgressStackOptions {
diff --git a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts
index 4fb97ac68..acb4a21ef 100644
--- a/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts
+++ b/sdks/sandbox/javascript/src/factory/defaultAdapterFactory.ts
@@ -21,6 +21,7 @@ import { EgressAdapter } from "../adapters/egressAdapter.js";
import { FilesystemAdapter } from "../adapters/filesystemAdapter.js";
import { HealthAdapter } from "../adapters/healthAdapter.js";
import { MetricsAdapter } from "../adapters/metricsAdapter.js";
+import { PtyAdapter } from "../adapters/ptyAdapter.js";
import { SandboxesAdapter } from "../adapters/sandboxesAdapter.js";
import type {
@@ -58,6 +59,7 @@ export class DefaultAdapterFactory implements AdapterFactory {
const health = new HealthAdapter(execdClient);
const metrics = new MetricsAdapter(execdClient);
+ const pty = new PtyAdapter(execdClient);
const files = new FilesystemAdapter(execdClient, {
baseUrl: opts.execdBaseUrl,
fetch: opts.connectionConfig.fetch,
@@ -74,6 +76,7 @@ export class DefaultAdapterFactory implements AdapterFactory {
files,
health,
metrics,
+ pty,
};
}
diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts
index 70d512829..a516fcb4f 100644
--- a/sdks/sandbox/javascript/src/index.ts
+++ b/sdks/sandbox/javascript/src/index.ts
@@ -104,8 +104,11 @@ export type {
Metrics,
SandboxMetrics,
PingResponse,
+ PtySession,
+ PtySessionStatus,
} from "./models/execd.js";
export type { ExecdCommands } from "./services/execdCommands.js";
+export type { ExecdPty } from "./services/execdPty.js";
export type {
Execution,
diff --git a/sdks/sandbox/javascript/src/internal.ts b/sdks/sandbox/javascript/src/internal.ts
index cdaff43c5..39eb9f651 100644
--- a/sdks/sandbox/javascript/src/internal.ts
+++ b/sdks/sandbox/javascript/src/internal.ts
@@ -42,3 +42,4 @@ export { HealthAdapter } from "./adapters/healthAdapter.js";
export { MetricsAdapter } from "./adapters/metricsAdapter.js";
export { FilesystemAdapter } from "./adapters/filesystemAdapter.js";
export { CommandsAdapter } from "./adapters/commandsAdapter.js";
+export { PtyAdapter, UnavailablePtyAdapter } from "./adapters/ptyAdapter.js";
diff --git a/sdks/sandbox/javascript/src/models/execd.ts b/sdks/sandbox/javascript/src/models/execd.ts
index 992dd8941..0e5b4aeb5 100644
--- a/sdks/sandbox/javascript/src/models/execd.ts
+++ b/sdks/sandbox/javascript/src/models/execd.ts
@@ -113,4 +113,24 @@ export interface SandboxMetrics {
timestamp: number;
}
-export type PingResponse = Record;
\ No newline at end of file
+export type PingResponse = Record;
+
+/**
+ * A created PTY session. The shell starts on the first WebSocket attach.
+ */
+export interface PtySession {
+ sessionId: string;
+}
+
+/**
+ * Current status of a PTY session.
+ */
+export interface PtySessionStatus {
+ sessionId: string;
+ running: boolean;
+ /**
+ * Byte offset of buffered output; pass it as `since` on reconnect to replay
+ * scrollback from that point.
+ */
+ outputOffset: number;
+}
\ No newline at end of file
diff --git a/sdks/sandbox/javascript/src/sandbox.ts b/sdks/sandbox/javascript/src/sandbox.ts
index 3751dd9e4..40c11024f 100644
--- a/sdks/sandbox/javascript/src/sandbox.ts
+++ b/sdks/sandbox/javascript/src/sandbox.ts
@@ -31,6 +31,8 @@ import type { Sandboxes } from "./services/sandboxes.js";
import type { ExecdCommands } from "./services/execdCommands.js";
import type { ExecdHealth } from "./services/execdHealth.js";
import type { ExecdMetrics } from "./services/execdMetrics.js";
+import type { ExecdPty } from "./services/execdPty.js";
+import { UnavailablePtyAdapter } from "./adapters/ptyAdapter.js";
import type {
CreateSandboxRequest,
CredentialProxyConfig,
@@ -240,6 +242,10 @@ export class Sandbox {
readonly files: SandboxFiles;
readonly health: ExecdHealth;
readonly metrics: ExecdMetrics;
+ /**
+ * Interactive PTY (pseudo-terminal) session lifecycle (create / status / delete).
+ */
+ readonly pty: ExecdPty;
/**
* Sandbox-scoped Credential Vault operations.
*/
@@ -271,6 +277,7 @@ export class Sandbox {
files: SandboxFiles;
health: ExecdHealth;
metrics: ExecdMetrics;
+ pty?: ExecdPty;
egress: Egress;
credentialVault?: CredentialVault;
}) {
@@ -294,6 +301,7 @@ export class Sandbox {
this.files = opts.files;
this.health = opts.health;
this.metrics = opts.metrics;
+ this.pty = opts.pty ?? new UnavailablePtyAdapter();
this.credentialVault = credentialVault;
}
@@ -395,7 +403,7 @@ export class Sandbox {
const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`;
const egressBaseUrl = `${connectionConfig.protocol}://${egressEndpoint.endpoint}`;
- const { commands, files, health, metrics } =
+ const { commands, files, health, metrics, pty } =
adapterFactory.createExecdStack({
connectionConfig,
execdBaseUrl,
@@ -418,6 +426,7 @@ export class Sandbox {
files,
health,
metrics,
+ pty,
egress,
credentialVault,
});
@@ -480,7 +489,7 @@ export class Sandbox {
);
const execdBaseUrl = `${connectionConfig.protocol}://${endpoint.endpoint}`;
const egressBaseUrl = `${connectionConfig.protocol}://${egressEndpoint.endpoint}`;
- const { commands, files, health, metrics } =
+ const { commands, files, health, metrics, pty } =
adapterFactory.createExecdStack({
connectionConfig,
execdBaseUrl,
@@ -503,6 +512,7 @@ export class Sandbox {
files,
health,
metrics,
+ pty,
egress,
credentialVault,
});
diff --git a/sdks/sandbox/javascript/src/services/execdPty.ts b/sdks/sandbox/javascript/src/services/execdPty.ts
new file mode 100644
index 000000000..378b73f79
--- /dev/null
+++ b/sdks/sandbox/javascript/src/services/execdPty.ts
@@ -0,0 +1,31 @@
+// 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.
+
+import type { PtySession, PtySessionStatus } from "../models/execd.js";
+
+/**
+ * Interactive PTY session lifecycle for a sandbox.
+ *
+ * Manages the session lifecycle over execd's REST API (create / status / delete). Attaching to
+ * the interactive `/pty/{sessionId}/ws` WebSocket stream is a separate concern. PTY is only
+ * supported on Unix-like platforms.
+ */
+export interface ExecdPty {
+ /** Create a new PTY session. The shell starts on the first WebSocket attach. */
+ createSession(opts?: { cwd?: string; command?: string }): Promise;
+ /** Retrieve the current status of a PTY session. */
+ getSession(sessionId: string): Promise;
+ /** Tear down a PTY session on the server side. */
+ deleteSession(sessionId: string): Promise;
+}
diff --git a/sdks/sandbox/javascript/tests/pty.test.mjs b/sdks/sandbox/javascript/tests/pty.test.mjs
new file mode 100644
index 000000000..57c23d331
--- /dev/null
+++ b/sdks/sandbox/javascript/tests/pty.test.mjs
@@ -0,0 +1,92 @@
+// 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.
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { PtyAdapter, UnavailablePtyAdapter, createExecdClient } from "../dist/internal.js";
+
+test("PtyAdapter.createSession posts to /pty and maps session_id", async () => {
+ const pty = new PtyAdapter(createExecdClient({
+ baseUrl: "http://execd.test",
+ async fetch(request) {
+ assert.equal(new URL(request.url).pathname, "/pty");
+ assert.equal(request.method, "POST");
+ return Response.json({ session_id: "sess-123" }, { status: 201 });
+ },
+ }));
+
+ const session = await pty.createSession({ cwd: "/tmp", command: "bash" });
+ assert.equal(session.sessionId, "sess-123");
+});
+
+test("PtyAdapter.getSession maps running and output_offset", async () => {
+ const pty = new PtyAdapter(createExecdClient({
+ baseUrl: "http://execd.test",
+ async fetch(request) {
+ assert.equal(new URL(request.url).pathname, "/pty/sess-123");
+ return Response.json(
+ { session_id: "sess-123", running: true, output_offset: 4096 },
+ { status: 200 },
+ );
+ },
+ }));
+
+ const status = await pty.getSession("sess-123");
+ assert.equal(status.sessionId, "sess-123");
+ assert.equal(status.running, true);
+ assert.equal(status.outputOffset, 4096);
+});
+
+test("PtyAdapter.deleteSession issues a DELETE", async () => {
+ let method;
+ const pty = new PtyAdapter(createExecdClient({
+ baseUrl: "http://execd.test",
+ async fetch(request) {
+ method = request.method;
+ assert.equal(new URL(request.url).pathname, "/pty/sess-123");
+ // Empty success body, as the server sends it (Content-Length: 0).
+ return new Response(null, { status: 200, headers: { "Content-Length": "0" } });
+ },
+ }));
+
+ await pty.deleteSession("sess-123");
+ assert.equal(method, "DELETE");
+});
+
+test("PtyAdapter.deleteSession surfaces JSON error bodies", async () => {
+ const pty = new PtyAdapter(createExecdClient({
+ baseUrl: "http://execd.test",
+ async fetch() {
+ return Response.json(
+ { code: "CONTEXT_NOT_FOUND", message: "no such pty session" },
+ { status: 404 },
+ );
+ },
+ }));
+
+ await assert.rejects(pty.deleteSession("sess-404"), (err) => {
+ assert.equal(err.error?.code, "CONTEXT_NOT_FOUND");
+ assert.match(err.message, /no such pty session/);
+ return true;
+ });
+});
+
+test("UnavailablePtyAdapter throws a descriptive error on use", async () => {
+ const pty = new UnavailablePtyAdapter();
+ await assert.rejects(pty.createSession(), (err) => {
+ assert.match(err.message, /PTY service is not available/);
+ return true;
+ });
+});
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
index 8524ae15c..0bc851420 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt
@@ -42,6 +42,7 @@ import com.alibaba.opensandbox.sandbox.domain.services.Egress
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Health
import com.alibaba.opensandbox.sandbox.domain.services.Metrics
+import com.alibaba.opensandbox.sandbox.domain.services.Pty
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import com.alibaba.opensandbox.sandbox.infrastructure.factory.AdapterFactory
import org.slf4j.LoggerFactory
@@ -100,6 +101,15 @@ class Sandbox internal constructor(
) : AutoCloseable {
private val logger = LoggerFactory.getLogger(Sandbox::class.java)
+ // Wired by the factory immediately after construction via [bindPtyService] so the PTY service
+ // shares this sandbox's resolved execd endpoint, without changing this constructor's
+ // JVM-visible signature (which already-compiled consumers may depend on).
+ private lateinit var ptyService: Pty
+
+ internal fun bindPtyService(pty: Pty) {
+ ptyService = pty
+ }
+
/**
* Provides access to file system operations within the sandbox.
*
@@ -118,6 +128,16 @@ class Sandbox internal constructor(
*/
fun commands() = commandService
+ /**
+ * Provides access to interactive PTY (pseudo-terminal) session operations.
+ *
+ * Manages the lifecycle of long-lived shell sessions (create / status / delete) over execd's
+ * REST API. PTY is only supported on Unix-like platforms.
+ *
+ * @return Service for PTY session management
+ */
+ fun pty() = ptyService
+
/**
* Provides access to sandbox metrics and monitoring.
*
@@ -225,6 +245,7 @@ class Sandbox internal constructor(
)
val fileSystemService = factory.createFilesystem(execdEndpoint)
val commandService = factory.createCommands(execdEndpoint)
+ val ptyService = factory.createPty(execdEndpoint)
val metricsService = factory.createMetrics(execdEndpoint)
val healthService = factory.createHealth(execdEndpoint)
val egressEndpoint =
@@ -250,6 +271,7 @@ class Sandbox internal constructor(
httpClientProvider = httpClientProvider,
diagnosticsService = diagnosticsService,
)
+ sandbox.bindPtyService(ptyService)
if (!skipHealthCheck) {
sandbox.checkReady(timeout, healthCheckPollingInterval)
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/pty/PtyModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/pty/PtyModels.kt
new file mode 100644
index 000000000..a1c35c870
--- /dev/null
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/pty/PtyModels.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2025 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.
+ */
+
+package com.alibaba.opensandbox.sandbox.domain.models.execd.pty
+
+/**
+ * A created PTY session.
+ *
+ * The shell is not started until the first WebSocket attaches; this only
+ * identifies the server-side session.
+ *
+ * @property sessionId Server-assigned identifier of the PTY session
+ */
+class PtySession(
+ val sessionId: String,
+)
+
+/**
+ * Current status of a PTY session.
+ *
+ * @property sessionId Identifier of the PTY session
+ * @property running Whether the underlying shell process is alive
+ * @property outputOffset Byte offset of the buffered output; pass it as `since`
+ * when reconnecting to replay scrollback from that point
+ */
+class PtySessionStatus(
+ val sessionId: String,
+ val running: Boolean,
+ val outputOffset: Long,
+)
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Pty.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Pty.kt
new file mode 100644
index 000000000..5ae505688
--- /dev/null
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Pty.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2025 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.
+ */
+
+package com.alibaba.opensandbox.sandbox.domain.services
+
+import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtySession
+import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtySessionStatus
+
+/**
+ * Interactive pseudo-terminal (PTY) session lifecycle for a sandbox.
+ *
+ * A PTY session is a long-lived shell driven over a WebSocket. This service manages the session
+ * lifecycle over execd's REST API ([createSession] / [getSession] / [deleteSession]). Attaching to
+ * the interactive stream (the `/pty/{sessionId}/ws` WebSocket) is a separate concern and is not
+ * part of this service.
+ *
+ * Explicit overloads (rather than Kotlin default arguments) are provided so the API stays
+ * ergonomic from Java, where interface default arguments are not emitted as overloads.
+ *
+ * PTY is only supported on Unix-like platforms (Linux/macOS).
+ */
+interface Pty {
+ /**
+ * Creates a new PTY session. The shell does not start until the first WebSocket attaches.
+ *
+ * @param cwd Optional working directory for the shell
+ * @param command Optional command to run instead of the default login shell
+ * @return The created session
+ */
+ fun createSession(
+ cwd: String?,
+ command: String?,
+ ): PtySession
+
+ /** Creates a new PTY session in the given working directory with the default shell. */
+ fun createSession(cwd: String?): PtySession = createSession(cwd, null)
+
+ /** Creates a new PTY session with the default working directory and shell. */
+ fun createSession(): PtySession = createSession(null, null)
+
+ /**
+ * Retrieves the current status of a PTY session.
+ *
+ * @param sessionId Identifier of the PTY session
+ * @return Session status, including the output offset usable for replay
+ */
+ fun getSession(sessionId: String): PtySessionStatus
+
+ /**
+ * Tears down a PTY session on the server side.
+ *
+ * @param sessionId Identifier of the PTY session
+ */
+ fun deleteSession(sessionId: String)
+}
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapter.kt
new file mode 100644
index 000000000..739ed4dcf
--- /dev/null
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapter.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2025 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.
+ */
+
+package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service
+
+import com.alibaba.opensandbox.sandbox.HttpClientProvider
+import com.alibaba.opensandbox.sandbox.api.execd.PTYApi
+import com.alibaba.opensandbox.sandbox.api.models.execd.CreatePtySessionRequest
+import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtySession
+import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtySessionStatus
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
+import com.alibaba.opensandbox.sandbox.domain.services.Pty
+import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException
+import org.slf4j.LoggerFactory
+
+/**
+ * Implementation of [Pty] that adapts the OpenAPI-generated [PTYApi] for the execd PTY session
+ * lifecycle. Mirrors the wiring of the other execd adapters: the generated client is bound to the
+ * resolved sandbox endpoint and carries its routing/auth headers, and errors are mapped through
+ * [toSandboxException].
+ */
+internal class PtyAdapter(
+ private val httpClientProvider: HttpClientProvider,
+ private val execdEndpoint: SandboxEndpoint,
+) : Pty {
+ private val logger = LoggerFactory.getLogger(PtyAdapter::class.java)
+ private val api =
+ PTYApi(
+ "${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}",
+ httpClientProvider.httpClient.newBuilder()
+ .addInterceptor { chain ->
+ val requestBuilder = chain.request().newBuilder()
+ execdEndpoint.headers.forEach { (key, value) ->
+ requestBuilder.header(key, value)
+ }
+ chain.proceed(requestBuilder.build())
+ }
+ .build(),
+ )
+
+ override fun createSession(
+ cwd: String?,
+ command: String?,
+ ): PtySession {
+ return try {
+ val response = api.createPtySession(CreatePtySessionRequest(cwd = cwd, command = command))
+ PtySession(response.sessionId)
+ } catch (e: Exception) {
+ logger.error("Failed to create PTY session", e)
+ throw e.toSandboxException()
+ }
+ }
+
+ override fun getSession(sessionId: String): PtySessionStatus {
+ return try {
+ val response = api.getPtySession(sessionId)
+ PtySessionStatus(response.sessionId, response.running, response.outputOffset)
+ } catch (e: Exception) {
+ logger.error("Failed to get PTY session {}", sessionId, e)
+ throw e.toSandboxException()
+ }
+ }
+
+ override fun deleteSession(sessionId: String) {
+ try {
+ api.deletePtySession(sessionId)
+ } catch (e: Exception) {
+ logger.error("Failed to delete PTY session {}", sessionId, e)
+ throw e.toSandboxException()
+ }
+ }
+}
diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt
index 9cc52f410..2f59e3bc0 100644
--- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/factory/AdapterFactory.kt
@@ -25,6 +25,7 @@ import com.alibaba.opensandbox.sandbox.domain.services.Egress
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Health
import com.alibaba.opensandbox.sandbox.domain.services.Metrics
+import com.alibaba.opensandbox.sandbox.domain.services.Pty
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.CommandsAdapter
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.DiagnosticsAdapter
@@ -32,6 +33,7 @@ import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.EgressAda
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.FilesystemAdapter
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.HealthAdapter
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.MetricsAdapter
+import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.PtyAdapter
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.SandboxesAdapter
/**
@@ -64,6 +66,10 @@ internal class AdapterFactory(
return CommandsAdapter(httpClientProvider, endpoint)
}
+ fun createPty(endpoint: SandboxEndpoint): Pty {
+ return PtyAdapter(httpClientProvider, endpoint)
+ }
+
fun createEgressStack(endpoint: SandboxEndpoint): EgressStack {
val adapter = EgressAdapter(httpClientProvider, endpoint)
return EgressStack(
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
index 683723496..4582f60fb 100644
--- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/SandboxTest.kt
@@ -32,6 +32,7 @@ import com.alibaba.opensandbox.sandbox.domain.services.Egress
import com.alibaba.opensandbox.sandbox.domain.services.Filesystem
import com.alibaba.opensandbox.sandbox.domain.services.Health
import com.alibaba.opensandbox.sandbox.domain.services.Metrics
+import com.alibaba.opensandbox.sandbox.domain.services.Pty
import com.alibaba.opensandbox.sandbox.domain.services.Sandboxes
import io.mockk.Runs
import io.mockk.every
@@ -76,6 +77,9 @@ class SandboxTest {
@MockK
lateinit var diagnosticsService: Diagnostics
+ @MockK
+ lateinit var ptyService: Pty
+
@MockK
lateinit var httpClientProvider: HttpClientProvider
@@ -106,6 +110,7 @@ class SandboxTest {
httpClientProvider = httpClientProvider,
diagnosticsService = diagnosticsService,
)
+ sandbox.bindPtyService(ptyService)
}
@Test
@@ -118,6 +123,11 @@ class SandboxTest {
assertSame(commandService, sandbox.commands())
}
+ @Test
+ fun `pty should return pty service`() {
+ assertSame(ptyService, sandbox.pty())
+ }
+
@Test
fun `metrics should return metrics service`() {
assertSame(metricsService, sandbox.metrics())
diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapterTest.kt
new file mode 100644
index 000000000..a9d7c08e5
--- /dev/null
+++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapterTest.kt
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2025 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.
+ */
+
+package com.alibaba.opensandbox.sandbox.infrastructure.adapters.service
+
+import com.alibaba.opensandbox.sandbox.HttpClientProvider
+import com.alibaba.opensandbox.sandbox.config.ConnectionConfig
+import com.alibaba.opensandbox.sandbox.domain.exceptions.SandboxApiException
+import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+
+class PtyAdapterTest {
+ private lateinit var mockWebServer: MockWebServer
+ private lateinit var httpClientProvider: HttpClientProvider
+ private lateinit var ptyAdapter: PtyAdapter
+
+ @BeforeEach
+ fun setUp() {
+ mockWebServer = MockWebServer()
+ mockWebServer.start()
+ val host = mockWebServer.hostName
+ val port = mockWebServer.port
+ val endpoint = SandboxEndpoint("$host:$port")
+ val config =
+ ConnectionConfig.builder()
+ .domain("$host:$port")
+ .protocol("http")
+ .build()
+ httpClientProvider = HttpClientProvider(config)
+ ptyAdapter = PtyAdapter(httpClientProvider, endpoint)
+ }
+
+ @AfterEach
+ fun tearDown() {
+ mockWebServer.shutdown()
+ }
+
+ @Test
+ fun `createSession should POST to pty and parse the session id`() {
+ mockWebServer.enqueue(
+ MockResponse().setResponseCode(201).setBody("""{"session_id":"sess-123"}"""),
+ )
+
+ val session = ptyAdapter.createSession(cwd = "/tmp", command = "bash")
+
+ assertEquals("sess-123", session.sessionId)
+ val recorded = mockWebServer.takeRequest()
+ assertEquals("/pty", recorded.path)
+ assertEquals("POST", recorded.method)
+ val body = Json.parseToJsonElement(recorded.body.readUtf8()).jsonObject
+ assertEquals("/tmp", body["cwd"]?.jsonPrimitive?.content)
+ assertEquals("bash", body["command"]?.jsonPrimitive?.content)
+ }
+
+ @Test
+ fun `getSession should parse running and output offset`() {
+ mockWebServer.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"session_id":"sess-123","running":true,"output_offset":4096}"""),
+ )
+
+ val status = ptyAdapter.getSession("sess-123")
+
+ assertEquals("sess-123", status.sessionId)
+ assertTrue(status.running)
+ assertEquals(4096L, status.outputOffset)
+ val recorded = mockWebServer.takeRequest()
+ assertEquals("/pty/sess-123", recorded.path)
+ assertEquals("GET", recorded.method)
+ }
+
+ @Test
+ fun `deleteSession should issue a DELETE`() {
+ mockWebServer.enqueue(MockResponse().setResponseCode(200))
+
+ ptyAdapter.deleteSession("sess-123")
+
+ val recorded = mockWebServer.takeRequest()
+ assertEquals("/pty/sess-123", recorded.path)
+ assertEquals("DELETE", recorded.method)
+ }
+
+ @Test
+ fun `createSession should map error responses to SandboxApiException`() {
+ mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody("boom"))
+
+ assertThrows {
+ ptyAdapter.createSession()
+ }
+ }
+
+ @Test
+ fun `endpoint headers should be forwarded to execd`() {
+ val host = mockWebServer.hostName
+ val port = mockWebServer.port
+ val endpointWithHeaders = SandboxEndpoint("$host:$port", mapOf("X-Test-Header" to "value-1"))
+ val adapter = PtyAdapter(httpClientProvider, endpointWithHeaders)
+ mockWebServer.enqueue(MockResponse().setResponseCode(201).setBody("""{"session_id":"sess-1"}"""))
+
+ adapter.createSession()
+
+ val recorded = mockWebServer.takeRequest()
+ assertEquals("value-1", recorded.getHeader("X-Test-Header"))
+ }
+}
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/__init__.py b/sdks/sandbox/python/src/opensandbox/adapters/__init__.py
index 5a482546d..fb7fce25b 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/__init__.py
@@ -25,6 +25,7 @@
from opensandbox.adapters.filesystem_adapter import FilesystemAdapter
from opensandbox.adapters.health_adapter import HealthAdapter
from opensandbox.adapters.metrics_adapter import MetricsAdapter
+from opensandbox.adapters.pty_adapter import PtyAdapter
from opensandbox.adapters.sandboxes_adapter import SandboxesAdapter
__all__ = [
@@ -35,4 +36,5 @@
"EgressAdapter",
"HealthAdapter",
"MetricsAdapter",
+ "PtyAdapter",
]
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/factory.py b/sdks/sandbox/python/src/opensandbox/adapters/factory.py
index cc591add5..a734c9edc 100644
--- a/sdks/sandbox/python/src/opensandbox/adapters/factory.py
+++ b/sdks/sandbox/python/src/opensandbox/adapters/factory.py
@@ -30,6 +30,7 @@
from opensandbox.adapters.filesystem_adapter import FilesystemAdapter
from opensandbox.adapters.health_adapter import HealthAdapter
from opensandbox.adapters.metrics_adapter import MetricsAdapter
+from opensandbox.adapters.pty_adapter import PtyAdapter
from opensandbox.adapters.sandboxes_adapter import SandboxesAdapter
from opensandbox.config import ConnectionConfig
from opensandbox.models.sandboxes import SandboxEndpoint
@@ -39,6 +40,7 @@
from opensandbox.services.filesystem import Filesystem
from opensandbox.services.health import Health
from opensandbox.services.metrics import Metrics
+from opensandbox.services.pty import Pty
from opensandbox.services.sandbox import Sandboxes
@@ -98,6 +100,17 @@ def create_command_service(self, endpoint: SandboxEndpoint) -> Commands:
"""
return CommandsAdapter(self.connection_config, endpoint)
+ def create_pty_service(self, endpoint: SandboxEndpoint) -> Pty:
+ """Create a PTY session service for interactive pseudo-terminal lifecycle.
+
+ Args:
+ endpoint: Sandbox endpoint information for the execd service
+
+ Returns:
+ Service for managing PTY sessions within the sandbox
+ """
+ return PtyAdapter(self.connection_config, endpoint)
+
def create_egress_service(self, endpoint: SandboxEndpoint) -> Egress:
"""Create a direct egress service for runtime egress policy operations."""
return EgressAdapter(self.connection_config, endpoint)
diff --git a/sdks/sandbox/python/src/opensandbox/adapters/pty_adapter.py b/sdks/sandbox/python/src/opensandbox/adapters/pty_adapter.py
new file mode 100644
index 000000000..97bf5fcf8
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/adapters/pty_adapter.py
@@ -0,0 +1,133 @@
+#
+# Copyright 2025 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.
+#
+"""
+PTY service adapter implementation.
+
+Implementation of the Pty service that adapts the openapi-python-client generated PTY API.
+"""
+
+import logging
+
+import httpx
+
+from opensandbox.config import ConnectionConfig
+from opensandbox.models.execd import PtySession, PtySessionStatus
+from opensandbox.models.sandboxes import SandboxEndpoint
+from opensandbox.services.pty import Pty
+
+logger = logging.getLogger(__name__)
+
+
+class PtyAdapter(Pty):
+ """
+ Implementation of the PTY session service backed by the generated execd PTY API client.
+ """
+
+ def __init__(
+ self,
+ connection_config: ConnectionConfig,
+ execd_endpoint: SandboxEndpoint,
+ ) -> None:
+ """
+ Initialize the PTY service adapter.
+
+ Args:
+ connection_config: Connection configuration (shared transport, headers, timeouts)
+ execd_endpoint: Endpoint for the execd service
+ """
+ self.connection_config = connection_config
+ self.execd_endpoint = execd_endpoint
+ from opensandbox.api.execd import Client
+
+ protocol = self.connection_config.protocol
+ base_url = f"{protocol}://{self.execd_endpoint.endpoint}"
+ timeout_seconds = self.connection_config.request_timeout.total_seconds()
+ timeout = httpx.Timeout(timeout_seconds)
+
+ headers = {
+ "User-Agent": self.connection_config.user_agent,
+ **self.connection_config.headers,
+ **self.execd_endpoint.headers,
+ }
+
+ # Execd API does not require authentication
+ self._client = Client(
+ base_url=base_url,
+ timeout=timeout,
+ )
+
+ self._httpx_client = httpx.AsyncClient(
+ base_url=base_url,
+ headers=headers,
+ timeout=timeout,
+ transport=self.connection_config.transport,
+ )
+ self._client.set_async_httpx_client(self._httpx_client)
+
+ async def create_session(
+ self,
+ cwd: str | None = None,
+ command: str | None = None,
+ ) -> PtySession:
+ from opensandbox.adapters.converter.response_handler import (
+ handle_api_error,
+ require_parsed,
+ )
+ from opensandbox.api.execd.api.pty import create_pty_session
+ from opensandbox.api.execd.models import CreatePtySessionResponse
+ from opensandbox.api.execd.models.create_pty_session_request import (
+ CreatePtySessionRequest,
+ )
+ from opensandbox.api.execd.types import UNSET
+
+ body = CreatePtySessionRequest(
+ cwd=cwd if cwd is not None else UNSET,
+ command=command if command is not None else UNSET,
+ )
+ response_obj = await create_pty_session.asyncio_detailed(
+ client=self._client, body=body
+ )
+ handle_api_error(response_obj, "Create PTY session")
+ parsed = require_parsed(response_obj, CreatePtySessionResponse, "Create PTY session")
+ return PtySession(session_id=parsed.session_id)
+
+ async def get_session(self, session_id: str) -> PtySessionStatus:
+ from opensandbox.adapters.converter.response_handler import (
+ handle_api_error,
+ require_parsed,
+ )
+ from opensandbox.api.execd.api.pty import get_pty_session
+ from opensandbox.api.execd.models import PtySessionStatusResponse
+
+ response_obj = await get_pty_session.asyncio_detailed(
+ session_id, client=self._client
+ )
+ handle_api_error(response_obj, "Get PTY session")
+ parsed = require_parsed(response_obj, PtySessionStatusResponse, "Get PTY session")
+ return PtySessionStatus(
+ session_id=parsed.session_id,
+ running=parsed.running,
+ output_offset=parsed.output_offset,
+ )
+
+ async def delete_session(self, session_id: str) -> None:
+ from opensandbox.adapters.converter.response_handler import handle_api_error
+ from opensandbox.api.execd.api.pty import delete_pty_session
+
+ response_obj = await delete_pty_session.asyncio_detailed(
+ session_id, client=self._client
+ )
+ handle_api_error(response_obj, "Delete PTY session")
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/__init__.py b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/__init__.py
new file mode 100644
index 000000000..20d4f8996
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/__init__.py
@@ -0,0 +1,17 @@
+#
+# 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.
+#
+
+"""Contains endpoint functions for accessing the API"""
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/create_pty_session.py b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/create_pty_session.py
new file mode 100644
index 000000000..19d9bfeff
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/create_pty_session.py
@@ -0,0 +1,213 @@
+#
+# 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.
+#
+
+from http import HTTPStatus
+from typing import Any
+
+import httpx
+
+from ... import errors
+from ...client import AuthenticatedClient, Client
+from ...models.create_pty_session_request import CreatePtySessionRequest
+from ...models.create_pty_session_response import CreatePtySessionResponse
+from ...models.error_response import ErrorResponse
+from ...types import UNSET, Response, Unset
+
+
+def _get_kwargs(
+ *,
+ body: CreatePtySessionRequest | Unset = UNSET,
+) -> dict[str, Any]:
+ headers: dict[str, Any] = {}
+
+ _kwargs: dict[str, Any] = {
+ "method": "post",
+ "url": "/pty",
+ }
+
+ if not isinstance(body, Unset):
+ _kwargs["json"] = body.to_dict()
+
+ headers["Content-Type"] = "application/json"
+
+ _kwargs["headers"] = headers
+ return _kwargs
+
+
+def _parse_response(
+ *, client: AuthenticatedClient | Client, response: httpx.Response
+) -> CreatePtySessionResponse | ErrorResponse | None:
+ if response.status_code == 201:
+ response_201 = CreatePtySessionResponse.from_dict(response.json())
+
+ return response_201
+
+ if response.status_code == 400:
+ response_400 = ErrorResponse.from_dict(response.json())
+
+ return response_400
+
+ if response.status_code == 500:
+ response_500 = ErrorResponse.from_dict(response.json())
+
+ return response_500
+
+ if response.status_code == 501:
+ response_501 = ErrorResponse.from_dict(response.json())
+
+ return response_501
+
+ if client.raise_on_unexpected_status:
+ raise errors.UnexpectedStatus(response.status_code, response.content)
+ else:
+ return None
+
+
+def _build_response(
+ *, client: AuthenticatedClient | Client, response: httpx.Response
+) -> Response[CreatePtySessionResponse | ErrorResponse]:
+ return Response(
+ status_code=HTTPStatus(response.status_code),
+ content=response.content,
+ headers=response.headers,
+ parsed=_parse_response(client=client, response=response),
+ )
+
+
+def sync_detailed(
+ *,
+ client: AuthenticatedClient | Client,
+ body: CreatePtySessionRequest | Unset = UNSET,
+) -> Response[CreatePtySessionResponse | ErrorResponse]:
+ """Create PTY session (create_pty_session)
+
+ Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+
+ Args:
+ body (CreatePtySessionRequest | Unset): Request to create a PTY session (optional body;
+ empty treated as defaults)
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[CreatePtySessionResponse | ErrorResponse]
+ """
+
+ kwargs = _get_kwargs(
+ body=body,
+ )
+
+ response = client.get_httpx_client().request(
+ **kwargs,
+ )
+
+ return _build_response(client=client, response=response)
+
+
+def sync(
+ *,
+ client: AuthenticatedClient | Client,
+ body: CreatePtySessionRequest | Unset = UNSET,
+) -> CreatePtySessionResponse | ErrorResponse | None:
+ """Create PTY session (create_pty_session)
+
+ Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+
+ Args:
+ body (CreatePtySessionRequest | Unset): Request to create a PTY session (optional body;
+ empty treated as defaults)
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ CreatePtySessionResponse | ErrorResponse
+ """
+
+ return sync_detailed(
+ client=client,
+ body=body,
+ ).parsed
+
+
+async def asyncio_detailed(
+ *,
+ client: AuthenticatedClient | Client,
+ body: CreatePtySessionRequest | Unset = UNSET,
+) -> Response[CreatePtySessionResponse | ErrorResponse]:
+ """Create PTY session (create_pty_session)
+
+ Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+
+ Args:
+ body (CreatePtySessionRequest | Unset): Request to create a PTY session (optional body;
+ empty treated as defaults)
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[CreatePtySessionResponse | ErrorResponse]
+ """
+
+ kwargs = _get_kwargs(
+ body=body,
+ )
+
+ response = await client.get_async_httpx_client().request(**kwargs)
+
+ return _build_response(client=client, response=response)
+
+
+async def asyncio(
+ *,
+ client: AuthenticatedClient | Client,
+ body: CreatePtySessionRequest | Unset = UNSET,
+) -> CreatePtySessionResponse | ErrorResponse | None:
+ """Create PTY session (create_pty_session)
+
+ Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+
+ Args:
+ body (CreatePtySessionRequest | Unset): Request to create a PTY session (optional body;
+ empty treated as defaults)
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ CreatePtySessionResponse | ErrorResponse
+ """
+
+ return (
+ await asyncio_detailed(
+ client=client,
+ body=body,
+ )
+ ).parsed
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/delete_pty_session.py b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/delete_pty_session.py
new file mode 100644
index 000000000..571b9d07a
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/delete_pty_session.py
@@ -0,0 +1,192 @@
+#
+# 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.
+#
+
+from http import HTTPStatus
+from typing import Any, cast
+from urllib.parse import quote
+
+import httpx
+
+from ... import errors
+from ...client import AuthenticatedClient, Client
+from ...models.error_response import ErrorResponse
+from ...types import Response
+
+
+def _get_kwargs(
+ session_id: str,
+) -> dict[str, Any]:
+ _kwargs: dict[str, Any] = {
+ "method": "delete",
+ "url": "/pty/{session_id}".format(
+ session_id=quote(str(session_id), safe=""),
+ ),
+ }
+
+ return _kwargs
+
+
+def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Any | ErrorResponse | None:
+ if response.status_code == 200:
+ response_200 = cast(Any, None)
+ return response_200
+
+ if response.status_code == 404:
+ response_404 = ErrorResponse.from_dict(response.json())
+
+ return response_404
+
+ if response.status_code == 500:
+ response_500 = ErrorResponse.from_dict(response.json())
+
+ return response_500
+
+ if response.status_code == 501:
+ response_501 = ErrorResponse.from_dict(response.json())
+
+ return response_501
+
+ if client.raise_on_unexpected_status:
+ raise errors.UnexpectedStatus(response.status_code, response.content)
+ else:
+ return None
+
+
+def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Any | ErrorResponse]:
+ return Response(
+ status_code=HTTPStatus(response.status_code),
+ content=response.content,
+ headers=response.headers,
+ parsed=_parse_response(client=client, response=response),
+ )
+
+
+def sync_detailed(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Response[Any | ErrorResponse]:
+ """Delete PTY session (delete_pty_session)
+
+ Tears down a PTY session on the server side, terminating the underlying shell process.
+ Returns 200 on success (the execd controller responds with an empty success body).
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[Any | ErrorResponse]
+ """
+
+ kwargs = _get_kwargs(
+ session_id=session_id,
+ )
+
+ response = client.get_httpx_client().request(
+ **kwargs,
+ )
+
+ return _build_response(client=client, response=response)
+
+
+def sync(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Any | ErrorResponse | None:
+ """Delete PTY session (delete_pty_session)
+
+ Tears down a PTY session on the server side, terminating the underlying shell process.
+ Returns 200 on success (the execd controller responds with an empty success body).
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Any | ErrorResponse
+ """
+
+ return sync_detailed(
+ session_id=session_id,
+ client=client,
+ ).parsed
+
+
+async def asyncio_detailed(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Response[Any | ErrorResponse]:
+ """Delete PTY session (delete_pty_session)
+
+ Tears down a PTY session on the server side, terminating the underlying shell process.
+ Returns 200 on success (the execd controller responds with an empty success body).
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[Any | ErrorResponse]
+ """
+
+ kwargs = _get_kwargs(
+ session_id=session_id,
+ )
+
+ response = await client.get_async_httpx_client().request(**kwargs)
+
+ return _build_response(client=client, response=response)
+
+
+async def asyncio(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Any | ErrorResponse | None:
+ """Delete PTY session (delete_pty_session)
+
+ Tears down a PTY session on the server side, terminating the underlying shell process.
+ Returns 200 on success (the execd controller responds with an empty success body).
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Any | ErrorResponse
+ """
+
+ return (
+ await asyncio_detailed(
+ session_id=session_id,
+ client=client,
+ )
+ ).parsed
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/get_pty_session.py b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/get_pty_session.py
new file mode 100644
index 000000000..73b166fc9
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/api/pty/get_pty_session.py
@@ -0,0 +1,194 @@
+#
+# 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.
+#
+
+from http import HTTPStatus
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+
+from ... import errors
+from ...client import AuthenticatedClient, Client
+from ...models.error_response import ErrorResponse
+from ...models.pty_session_status_response import PtySessionStatusResponse
+from ...types import Response
+
+
+def _get_kwargs(
+ session_id: str,
+) -> dict[str, Any]:
+ _kwargs: dict[str, Any] = {
+ "method": "get",
+ "url": "/pty/{session_id}".format(
+ session_id=quote(str(session_id), safe=""),
+ ),
+ }
+
+ return _kwargs
+
+
+def _parse_response(
+ *, client: AuthenticatedClient | Client, response: httpx.Response
+) -> ErrorResponse | PtySessionStatusResponse | None:
+ if response.status_code == 200:
+ response_200 = PtySessionStatusResponse.from_dict(response.json())
+
+ return response_200
+
+ if response.status_code == 404:
+ response_404 = ErrorResponse.from_dict(response.json())
+
+ return response_404
+
+ if response.status_code == 500:
+ response_500 = ErrorResponse.from_dict(response.json())
+
+ return response_500
+
+ if response.status_code == 501:
+ response_501 = ErrorResponse.from_dict(response.json())
+
+ return response_501
+
+ if client.raise_on_unexpected_status:
+ raise errors.UnexpectedStatus(response.status_code, response.content)
+ else:
+ return None
+
+
+def _build_response(
+ *, client: AuthenticatedClient | Client, response: httpx.Response
+) -> Response[ErrorResponse | PtySessionStatusResponse]:
+ return Response(
+ status_code=HTTPStatus(response.status_code),
+ content=response.content,
+ headers=response.headers,
+ parsed=_parse_response(client=client, response=response),
+ )
+
+
+def sync_detailed(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Response[ErrorResponse | PtySessionStatusResponse]:
+ """Get PTY session status (get_pty_session)
+
+ Returns the status of a PTY session, including the output offset usable for replay.
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[ErrorResponse | PtySessionStatusResponse]
+ """
+
+ kwargs = _get_kwargs(
+ session_id=session_id,
+ )
+
+ response = client.get_httpx_client().request(
+ **kwargs,
+ )
+
+ return _build_response(client=client, response=response)
+
+
+def sync(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> ErrorResponse | PtySessionStatusResponse | None:
+ """Get PTY session status (get_pty_session)
+
+ Returns the status of a PTY session, including the output offset usable for replay.
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ ErrorResponse | PtySessionStatusResponse
+ """
+
+ return sync_detailed(
+ session_id=session_id,
+ client=client,
+ ).parsed
+
+
+async def asyncio_detailed(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> Response[ErrorResponse | PtySessionStatusResponse]:
+ """Get PTY session status (get_pty_session)
+
+ Returns the status of a PTY session, including the output offset usable for replay.
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ Response[ErrorResponse | PtySessionStatusResponse]
+ """
+
+ kwargs = _get_kwargs(
+ session_id=session_id,
+ )
+
+ response = await client.get_async_httpx_client().request(**kwargs)
+
+ return _build_response(client=client, response=response)
+
+
+async def asyncio(
+ session_id: str,
+ *,
+ client: AuthenticatedClient | Client,
+) -> ErrorResponse | PtySessionStatusResponse | None:
+ """Get PTY session status (get_pty_session)
+
+ Returns the status of a PTY session, including the output offset usable for replay.
+
+ Args:
+ session_id (str):
+
+ Raises:
+ errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
+ httpx.TimeoutException: If the request takes longer than Client.timeout.
+
+ Returns:
+ ErrorResponse | PtySessionStatusResponse
+ """
+
+ return (
+ await asyncio_detailed(
+ session_id=session_id,
+ client=client,
+ )
+ ).parsed
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py
index e11692374..614a5026b 100644
--- a/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/__init__.py
@@ -20,6 +20,8 @@
from .code_context import CodeContext
from .code_context_request import CodeContextRequest
from .command_status_response import CommandStatusResponse
+from .create_pty_session_request import CreatePtySessionRequest
+from .create_pty_session_response import CreatePtySessionResponse
from .create_session_request import CreateSessionRequest
from .create_session_response import CreateSessionResponse
from .error_response import ErrorResponse
@@ -30,6 +32,7 @@
from .make_dirs_body import MakeDirsBody
from .metrics import Metrics
from .permission import Permission
+from .pty_session_status_response import PtySessionStatusResponse
from .rename_file_item import RenameFileItem
from .replace_content_body import ReplaceContentBody
from .replace_content_response_200 import ReplaceContentResponse200
@@ -50,6 +53,8 @@
"CodeContext",
"CodeContextRequest",
"CommandStatusResponse",
+ "CreatePtySessionRequest",
+ "CreatePtySessionResponse",
"CreateSessionRequest",
"CreateSessionResponse",
"ErrorResponse",
@@ -60,6 +65,7 @@
"MakeDirsBody",
"Metrics",
"Permission",
+ "PtySessionStatusResponse",
"RenameFileItem",
"ReplaceContentBody",
"ReplaceContentResponse200",
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_request.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_request.py
new file mode 100644
index 000000000..cd2a7c865
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_request.py
@@ -0,0 +1,87 @@
+#
+# 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.
+#
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, TypeVar
+
+from attrs import define as _attrs_define
+from attrs import field as _attrs_field
+
+from ..types import UNSET, Unset
+
+T = TypeVar("T", bound="CreatePtySessionRequest")
+
+
+@_attrs_define
+class CreatePtySessionRequest:
+ """Request to create a PTY session (optional body; empty treated as defaults)
+
+ Attributes:
+ cwd (str | Unset): Working directory for the shell Example: /workspace.
+ command (str | Unset): Command to run instead of the default login shell
+ """
+
+ cwd: str | Unset = UNSET
+ command: str | Unset = UNSET
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ cwd = self.cwd
+
+ command = self.command
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update({})
+ if cwd is not UNSET:
+ field_dict["cwd"] = cwd
+ if command is not UNSET:
+ field_dict["command"] = command
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ cwd = d.pop("cwd", UNSET)
+
+ command = d.pop("command", UNSET)
+
+ create_pty_session_request = cls(
+ cwd=cwd,
+ command=command,
+ )
+
+ create_pty_session_request.additional_properties = d
+ return create_pty_session_request
+
+ @property
+ def additional_keys(self) -> list[str]:
+ return list(self.additional_properties.keys())
+
+ def __getitem__(self, key: str) -> Any:
+ return self.additional_properties[key]
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ self.additional_properties[key] = value
+
+ def __delitem__(self, key: str) -> None:
+ del self.additional_properties[key]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self.additional_properties
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_response.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_response.py
new file mode 100644
index 000000000..f52bba17c
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_response.py
@@ -0,0 +1,77 @@
+#
+# 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.
+#
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, TypeVar
+
+from attrs import define as _attrs_define
+from attrs import field as _attrs_field
+
+T = TypeVar("T", bound="CreatePtySessionResponse")
+
+
+@_attrs_define
+class CreatePtySessionResponse:
+ """
+ Attributes:
+ session_id (str): Server-assigned identifier of the PTY session Example: pty-abc123.
+ """
+
+ session_id: str
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ session_id = self.session_id
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "session_id": session_id,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ session_id = d.pop("session_id")
+
+ create_pty_session_response = cls(
+ session_id=session_id,
+ )
+
+ create_pty_session_response.additional_properties = d
+ return create_pty_session_response
+
+ @property
+ def additional_keys(self) -> list[str]:
+ return list(self.additional_properties.keys())
+
+ def __getitem__(self, key: str) -> Any:
+ return self.additional_properties[key]
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ self.additional_properties[key] = value
+
+ def __delitem__(self, key: str) -> None:
+ del self.additional_properties[key]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self.additional_properties
diff --git a/sdks/sandbox/python/src/opensandbox/api/execd/models/pty_session_status_response.py b/sdks/sandbox/python/src/opensandbox/api/execd/models/pty_session_status_response.py
new file mode 100644
index 000000000..863a4b1a3
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/api/execd/models/pty_session_status_response.py
@@ -0,0 +1,93 @@
+#
+# 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.
+#
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, TypeVar
+
+from attrs import define as _attrs_define
+from attrs import field as _attrs_field
+
+T = TypeVar("T", bound="PtySessionStatusResponse")
+
+
+@_attrs_define
+class PtySessionStatusResponse:
+ """
+ Attributes:
+ session_id (str): Identifier of the PTY session Example: pty-abc123.
+ running (bool): Whether the underlying shell process is alive
+ output_offset (int): Byte offset of buffered output; pass as `since` on reconnect to replay scrollback
+ """
+
+ session_id: str
+ running: bool
+ output_offset: int
+ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ session_id = self.session_id
+
+ running = self.running
+
+ output_offset = self.output_offset
+
+ field_dict: dict[str, Any] = {}
+ field_dict.update(self.additional_properties)
+ field_dict.update(
+ {
+ "session_id": session_id,
+ "running": running,
+ "output_offset": output_offset,
+ }
+ )
+
+ return field_dict
+
+ @classmethod
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
+ d = dict(src_dict)
+ session_id = d.pop("session_id")
+
+ running = d.pop("running")
+
+ output_offset = d.pop("output_offset")
+
+ pty_session_status_response = cls(
+ session_id=session_id,
+ running=running,
+ output_offset=output_offset,
+ )
+
+ pty_session_status_response.additional_properties = d
+ return pty_session_status_response
+
+ @property
+ def additional_keys(self) -> list[str]:
+ return list(self.additional_properties.keys())
+
+ def __getitem__(self, key: str) -> Any:
+ return self.additional_properties[key]
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ self.additional_properties[key] = value
+
+ def __delitem__(self, key: str) -> None:
+ del self.additional_properties[key]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self.additional_properties
diff --git a/sdks/sandbox/python/src/opensandbox/models/execd.py b/sdks/sandbox/python/src/opensandbox/models/execd.py
index 0ab218728..a1eb37869 100644
--- a/sdks/sandbox/python/src/opensandbox/models/execd.py
+++ b/sdks/sandbox/python/src/opensandbox/models/execd.py
@@ -348,3 +348,27 @@ class CommandLogs(BaseModel):
default=None,
description="Latest tail cursor for incremental reads",
)
+
+
+class PtySession(BaseModel):
+ """
+ A created PTY session. The shell starts on the first WebSocket attach.
+ """
+
+ session_id: str = Field(description="Server-assigned identifier of the PTY session")
+
+ model_config = ConfigDict(populate_by_name=True)
+
+
+class PtySessionStatus(BaseModel):
+ """
+ Current status of a PTY session.
+ """
+
+ session_id: str = Field(description="Identifier of the PTY session")
+ running: bool = Field(description="Whether the underlying shell process is alive")
+ output_offset: int = Field(
+ description="Byte offset of buffered output; pass as `since` on reconnect to replay"
+ )
+
+ model_config = ConfigDict(populate_by_name=True)
diff --git a/sdks/sandbox/python/src/opensandbox/sandbox.py b/sdks/sandbox/python/src/opensandbox/sandbox.py
index 01d89e181..f6c403f8b 100644
--- a/sdks/sandbox/python/src/opensandbox/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/sandbox.py
@@ -56,6 +56,7 @@
Filesystem,
Health,
Metrics,
+ Pty,
Sandboxes,
)
@@ -124,6 +125,7 @@ def __init__(
connection_config: ConnectionConfig,
diagnostics_service: Diagnostics | None = None,
custom_health_check: Callable[["Sandbox"], Awaitable[bool]] | None = None,
+ pty_service: Pty | None = None,
) -> None:
"""
Internal constructor for Sandbox. Use Sandbox.create() or Sandbox.connect() instead.
@@ -132,6 +134,7 @@ def __init__(
self._sandbox_service = sandbox_service
self._filesystem_service = filesystem_service
self._command_service = command_service
+ self._pty_service = pty_service
self._health_service = health_service
self._metrics_service = metrics_service
self._egress_service = egress_service
@@ -159,6 +162,18 @@ def commands(self) -> Commands:
"""
return self._command_service
+ @property
+ def pty(self) -> Pty:
+ """
+ Provides access to interactive PTY (pseudo-terminal) session operations.
+
+ Manages the lifecycle of long-lived shell sessions (create / status / delete) over
+ execd's REST API. PTY is only supported on Unix-like platforms.
+ """
+ if self._pty_service is None:
+ raise RuntimeError("PTY service is not available on this sandbox instance")
+ return self._pty_service
+
@property
def metrics(self) -> Metrics:
"""
@@ -584,6 +599,7 @@ async def create(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
@@ -681,6 +697,7 @@ async def connect(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
@@ -757,6 +774,7 @@ async def resume(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
diff --git a/sdks/sandbox/python/src/opensandbox/services/__init__.py b/sdks/sandbox/python/src/opensandbox/services/__init__.py
index d9c06e06c..9fe4e6443 100644
--- a/sdks/sandbox/python/src/opensandbox/services/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/services/__init__.py
@@ -25,6 +25,7 @@
from opensandbox.services.filesystem import Filesystem
from opensandbox.services.health import Health
from opensandbox.services.metrics import Metrics
+from opensandbox.services.pty import Pty
from opensandbox.services.sandbox import Sandboxes
__all__ = [
@@ -35,5 +36,6 @@
"Filesystem",
"Health",
"Metrics",
+ "Pty",
"Sandboxes",
]
diff --git a/sdks/sandbox/python/src/opensandbox/services/pty.py b/sdks/sandbox/python/src/opensandbox/services/pty.py
new file mode 100644
index 000000000..bc5d40609
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/services/pty.py
@@ -0,0 +1,72 @@
+#
+# Copyright 2025 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.
+#
+"""
+PTY service interface.
+
+Protocol for sandbox interactive pseudo-terminal (PTY) session lifecycle.
+"""
+
+from typing import Protocol
+
+from opensandbox.models.execd import PtySession, PtySessionStatus
+
+
+class Pty(Protocol):
+ """
+ Interactive PTY session lifecycle for a sandbox.
+
+ Manages the session lifecycle over execd's REST API (create / status / delete).
+ Attaching to the interactive ``/pty/{sessionId}/ws`` WebSocket stream is a separate
+ concern and is not part of this service. PTY is only supported on Unix-like platforms.
+ """
+
+ async def create_session(
+ self,
+ cwd: str | None = None,
+ command: str | None = None,
+ ) -> PtySession:
+ """
+ Create a new PTY session. The shell does not start until the first WebSocket attaches.
+
+ Args:
+ cwd: Optional working directory for the shell
+ command: Optional command to run instead of the default login shell
+
+ Returns:
+ The created session
+ """
+ ...
+
+ async def get_session(self, session_id: str) -> PtySessionStatus:
+ """
+ Retrieve the current status of a PTY session.
+
+ Args:
+ session_id: Identifier of the PTY session
+
+ Returns:
+ Session status, including the output offset usable for replay
+ """
+ ...
+
+ async def delete_session(self, session_id: str) -> None:
+ """
+ Tear down a PTY session on the server side.
+
+ Args:
+ session_id: Identifier of the PTY session
+ """
+ ...
diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/__init__.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/__init__.py
index 49f61ce15..94f4cb35c 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/adapters/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/__init__.py
@@ -23,6 +23,7 @@
from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync
from opensandbox.sync.adapters.health_adapter import HealthAdapterSync
from opensandbox.sync.adapters.metrics_adapter import MetricsAdapterSync
+from opensandbox.sync.adapters.pty_adapter import PtyAdapterSync
from opensandbox.sync.adapters.sandboxes_adapter import SandboxesAdapterSync
__all__ = [
@@ -31,6 +32,7 @@
"FilesystemAdapterSync",
"HealthAdapterSync",
"MetricsAdapterSync",
+ "PtyAdapterSync",
"SandboxesAdapterSync",
"AdapterFactorySync",
]
diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py
index 5784910e4..0212adabe 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/factory.py
@@ -25,6 +25,7 @@
from opensandbox.sync.adapters.filesystem_adapter import FilesystemAdapterSync
from opensandbox.sync.adapters.health_adapter import HealthAdapterSync
from opensandbox.sync.adapters.metrics_adapter import MetricsAdapterSync
+from opensandbox.sync.adapters.pty_adapter import PtyAdapterSync
from opensandbox.sync.adapters.sandboxes_adapter import SandboxesAdapterSync
from opensandbox.sync.services import (
CommandsSync,
@@ -33,6 +34,7 @@
FilesystemSync,
HealthSync,
MetricsSync,
+ PtySync,
SandboxesSync,
)
@@ -53,6 +55,9 @@ def create_filesystem_service(self, endpoint: SandboxEndpoint) -> FilesystemSync
def create_command_service(self, endpoint: SandboxEndpoint) -> CommandsSync:
return CommandsAdapterSync(self.connection_config, endpoint)
+ def create_pty_service(self, endpoint: SandboxEndpoint) -> PtySync:
+ return PtyAdapterSync(self.connection_config, endpoint)
+
def create_egress_service(self, endpoint: SandboxEndpoint) -> EgressSync:
return EgressAdapterSync(self.connection_config, endpoint)
diff --git a/sdks/sandbox/python/src/opensandbox/sync/adapters/pty_adapter.py b/sdks/sandbox/python/src/opensandbox/sync/adapters/pty_adapter.py
new file mode 100644
index 000000000..a33f5e80e
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/sync/adapters/pty_adapter.py
@@ -0,0 +1,110 @@
+#
+# Copyright 2025 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.
+#
+"""
+Synchronous PTY service adapter implementation.
+
+Implementation of PtySync that adapts the openapi-python-client generated PTY API (sync SDK).
+"""
+
+import logging
+
+import httpx
+
+from opensandbox.config.connection_sync import ConnectionConfigSync
+from opensandbox.models.execd import PtySession, PtySessionStatus
+from opensandbox.models.sandboxes import SandboxEndpoint
+from opensandbox.sync.services.pty import PtySync
+
+logger = logging.getLogger(__name__)
+
+
+class PtyAdapterSync(PtySync):
+ """Synchronous PTY session service backed by the generated execd PTY API client."""
+
+ def __init__(
+ self,
+ connection_config: ConnectionConfigSync,
+ execd_endpoint: SandboxEndpoint,
+ ) -> None:
+ self.connection_config = connection_config
+ self.execd_endpoint = execd_endpoint
+ from opensandbox.api.execd import Client
+
+ base_url = f"{self.connection_config.protocol}://{self.execd_endpoint.endpoint}"
+ timeout = httpx.Timeout(self.connection_config.request_timeout.total_seconds())
+ headers = {
+ "User-Agent": self.connection_config.user_agent,
+ **self.connection_config.headers,
+ **self.execd_endpoint.headers,
+ }
+
+ self._client = Client(base_url=base_url, timeout=timeout)
+ self._httpx_client = httpx.Client(
+ base_url=base_url,
+ headers=headers,
+ timeout=timeout,
+ transport=self.connection_config.transport,
+ )
+ self._client.set_httpx_client(self._httpx_client)
+
+ def create_session(
+ self,
+ cwd: str | None = None,
+ command: str | None = None,
+ ) -> PtySession:
+ from opensandbox.adapters.converter.response_handler import (
+ handle_api_error,
+ require_parsed,
+ )
+ from opensandbox.api.execd.api.pty import create_pty_session
+ from opensandbox.api.execd.models import CreatePtySessionResponse
+ from opensandbox.api.execd.models.create_pty_session_request import (
+ CreatePtySessionRequest,
+ )
+ from opensandbox.api.execd.types import UNSET
+
+ body = CreatePtySessionRequest(
+ cwd=cwd if cwd is not None else UNSET,
+ command=command if command is not None else UNSET,
+ )
+ response_obj = create_pty_session.sync_detailed(client=self._client, body=body)
+ handle_api_error(response_obj, "Create PTY session")
+ parsed = require_parsed(response_obj, CreatePtySessionResponse, "Create PTY session")
+ return PtySession(session_id=parsed.session_id)
+
+ def get_session(self, session_id: str) -> PtySessionStatus:
+ from opensandbox.adapters.converter.response_handler import (
+ handle_api_error,
+ require_parsed,
+ )
+ from opensandbox.api.execd.api.pty import get_pty_session
+ from opensandbox.api.execd.models import PtySessionStatusResponse
+
+ response_obj = get_pty_session.sync_detailed(session_id, client=self._client)
+ handle_api_error(response_obj, "Get PTY session")
+ parsed = require_parsed(response_obj, PtySessionStatusResponse, "Get PTY session")
+ return PtySessionStatus(
+ session_id=parsed.session_id,
+ running=parsed.running,
+ output_offset=parsed.output_offset,
+ )
+
+ def delete_session(self, session_id: str) -> None:
+ from opensandbox.adapters.converter.response_handler import handle_api_error
+ from opensandbox.api.execd.api.pty import delete_pty_session
+
+ response_obj = delete_pty_session.sync_detailed(session_id, client=self._client)
+ handle_api_error(response_obj, "Delete PTY session")
diff --git a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
index 4fa6625ad..6e2a9d782 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/sandbox.py
@@ -55,6 +55,7 @@
FilesystemSync,
HealthSync,
MetricsSync,
+ PtySync,
SandboxesSync,
)
@@ -130,6 +131,7 @@ def __init__(
connection_config: ConnectionConfigSync,
diagnostics_service: DiagnosticsSync | None = None,
custom_health_check: Callable[["SandboxSync"], bool] | None = None,
+ pty_service: PtySync | None = None,
) -> None:
"""
Internal constructor for SandboxSync. Use :meth:`create` or :meth:`connect` instead.
@@ -138,6 +140,7 @@ def __init__(
self._sandbox_service = sandbox_service
self._filesystem_service = filesystem_service
self._command_service = command_service
+ self._pty_service = pty_service
self._health_service = health_service
self._metrics_service = metrics_service
self._egress_service = egress_service
@@ -165,6 +168,18 @@ def commands(self) -> CommandsSync:
"""
return self._command_service
+ @property
+ def pty(self) -> PtySync:
+ """
+ Provides access to interactive PTY (pseudo-terminal) session operations.
+
+ Manages the lifecycle of long-lived shell sessions (create / status / delete) over
+ execd's REST API. PTY is only supported on Unix-like platforms.
+ """
+ if self._pty_service is None:
+ raise RuntimeError("PTY service is not available on this sandbox instance")
+ return self._pty_service
+
@property
def metrics(self) -> MetricsSync:
"""
@@ -572,6 +587,7 @@ def create(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
@@ -656,6 +672,7 @@ def connect(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
@@ -732,6 +749,7 @@ def resume(
sandbox_service=sandbox_service,
filesystem_service=factory.create_filesystem_service(execd_endpoint),
command_service=factory.create_command_service(execd_endpoint),
+ pty_service=factory.create_pty_service(execd_endpoint),
health_service=factory.create_health_service(execd_endpoint),
metrics_service=factory.create_metrics_service(execd_endpoint),
egress_service=factory.create_egress_service(egress_endpoint),
diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py b/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py
index c426faa3e..7c74bd4e5 100644
--- a/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py
+++ b/sdks/sandbox/python/src/opensandbox/sync/services/__init__.py
@@ -23,6 +23,7 @@
from opensandbox.sync.services.filesystem import FilesystemSync
from opensandbox.sync.services.health import HealthSync
from opensandbox.sync.services.metrics import MetricsSync
+from opensandbox.sync.services.pty import PtySync
from opensandbox.sync.services.sandbox import SandboxesSync
__all__ = [
@@ -33,5 +34,6 @@
"FilesystemSync",
"HealthSync",
"MetricsSync",
+ "PtySync",
"SandboxesSync",
]
diff --git a/sdks/sandbox/python/src/opensandbox/sync/services/pty.py b/sdks/sandbox/python/src/opensandbox/sync/services/pty.py
new file mode 100644
index 000000000..4689e2705
--- /dev/null
+++ b/sdks/sandbox/python/src/opensandbox/sync/services/pty.py
@@ -0,0 +1,50 @@
+#
+# Copyright 2025 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.
+#
+"""
+Synchronous PTY service interface.
+
+Protocol for sandbox interactive pseudo-terminal (PTY) session lifecycle (sync SDK).
+"""
+
+from typing import Protocol
+
+from opensandbox.models.execd import PtySession, PtySessionStatus
+
+
+class PtySync(Protocol):
+ """
+ Interactive PTY session lifecycle for a sandbox (synchronous).
+
+ Manages the session lifecycle over execd's REST API (create / status / delete). Attaching to
+ the interactive ``/pty/{sessionId}/ws`` WebSocket stream is a separate concern and is not part
+ of this service. PTY is only supported on Unix-like platforms.
+ """
+
+ def create_session(
+ self,
+ cwd: str | None = None,
+ command: str | None = None,
+ ) -> PtySession:
+ """Create a new PTY session. The shell starts on the first WebSocket attach."""
+ ...
+
+ def get_session(self, session_id: str) -> PtySessionStatus:
+ """Retrieve the current status of a PTY session."""
+ ...
+
+ def delete_session(self, session_id: str) -> None:
+ """Tear down a PTY session on the server side."""
+ ...
diff --git a/sdks/sandbox/python/tests/test_pty_service_adapter.py b/sdks/sandbox/python/tests/test_pty_service_adapter.py
new file mode 100644
index 000000000..eaf0964af
--- /dev/null
+++ b/sdks/sandbox/python/tests/test_pty_service_adapter.py
@@ -0,0 +1,101 @@
+#
+# 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.
+#
+from __future__ import annotations
+
+import pytest
+
+from opensandbox.adapters.pty_adapter import PtyAdapter
+from opensandbox.api.execd.models import (
+ CreatePtySessionResponse,
+ PtySessionStatusResponse,
+)
+from opensandbox.config import ConnectionConfig
+from opensandbox.models.sandboxes import SandboxEndpoint
+
+
+class _Resp:
+ def __init__(self, *, status_code: int, parsed=None) -> None:
+ self.status_code = status_code
+ self.parsed = parsed
+ self.headers = {}
+
+
+def _adapter() -> PtyAdapter:
+ return PtyAdapter(
+ ConnectionConfig(domain="example.com:8080", api_key="k"),
+ SandboxEndpoint(endpoint="example.com:8080"),
+ )
+
+
+@pytest.mark.asyncio
+async def test_create_session_maps_request_and_response(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ called = {}
+
+ async def _fake(*, client, body):
+ called["cwd"] = body.cwd
+ called["command"] = body.command
+ return _Resp(status_code=201, parsed=CreatePtySessionResponse(session_id="sess-123"))
+
+ monkeypatch.setattr(
+ "opensandbox.api.execd.api.pty.create_pty_session.asyncio_detailed", _fake
+ )
+
+ session = await _adapter().create_session(cwd="/tmp", command="bash")
+
+ assert session.session_id == "sess-123"
+ assert called["cwd"] == "/tmp"
+ assert called["command"] == "bash"
+
+
+@pytest.mark.asyncio
+async def test_get_session_maps_status(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake(session_id, *, client):
+ assert session_id == "sess-123"
+ return _Resp(
+ status_code=200,
+ parsed=PtySessionStatusResponse(
+ session_id="sess-123", running=True, output_offset=4096
+ ),
+ )
+
+ monkeypatch.setattr(
+ "opensandbox.api.execd.api.pty.get_pty_session.asyncio_detailed", _fake
+ )
+
+ status = await _adapter().get_session("sess-123")
+
+ assert status.session_id == "sess-123"
+ assert status.running is True
+ assert status.output_offset == 4096
+
+
+@pytest.mark.asyncio
+async def test_delete_session_calls_api(monkeypatch: pytest.MonkeyPatch) -> None:
+ called = {}
+
+ async def _fake(session_id, *, client):
+ called["session_id"] = session_id
+ return _Resp(status_code=200)
+
+ monkeypatch.setattr(
+ "opensandbox.api.execd.api.pty.delete_pty_session.asyncio_detailed", _fake
+ )
+
+ await _adapter().delete_session("sess-123")
+
+ assert called["session_id"] == "sess-123"
diff --git a/sdks/sandbox/python/tests/test_sandbox_business_logic.py b/sdks/sandbox/python/tests/test_sandbox_business_logic.py
index f8fafc718..291fe0a8e 100644
--- a/sdks/sandbox/python/tests/test_sandbox_business_logic.py
+++ b/sdks/sandbox/python/tests/test_sandbox_business_logic.py
@@ -307,6 +307,9 @@ def create_filesystem_service(self, endpoint: SandboxEndpoint):
def create_command_service(self, endpoint: SandboxEndpoint):
return _Noop()
+ def create_pty_service(self, endpoint: SandboxEndpoint):
+ return _Noop()
+
def create_health_service(self, endpoint: SandboxEndpoint):
return _Noop()
@@ -432,6 +435,9 @@ def create_filesystem_service(self, _endpoint: SandboxEndpoint):
def create_command_service(self, _endpoint: SandboxEndpoint):
return _Noop()
+ def create_pty_service(self, _endpoint: SandboxEndpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint: SandboxEndpoint):
return _Noop()
@@ -518,6 +524,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
@@ -602,6 +611,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
@@ -675,6 +687,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
diff --git a/sdks/sandbox/python/tests/test_sandbox_sync_business_logic.py b/sdks/sandbox/python/tests/test_sandbox_sync_business_logic.py
index 9be4c7ed5..5fb35136f 100644
--- a/sdks/sandbox/python/tests/test_sandbox_sync_business_logic.py
+++ b/sdks/sandbox/python/tests/test_sandbox_sync_business_logic.py
@@ -216,6 +216,9 @@ def create_filesystem_service(self, endpoint: SandboxEndpoint):
def create_command_service(self, endpoint: SandboxEndpoint):
return _Noop()
+ def create_pty_service(self, endpoint: SandboxEndpoint):
+ return _Noop()
+
def create_health_service(self, endpoint: SandboxEndpoint):
return _Noop()
@@ -307,6 +310,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
@@ -365,6 +371,9 @@ def create_filesystem_service(self, _endpoint: SandboxEndpoint):
def create_command_service(self, _endpoint: SandboxEndpoint):
return _Noop()
+ def create_pty_service(self, _endpoint: SandboxEndpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint: SandboxEndpoint):
return _Noop()
@@ -450,6 +459,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
@@ -522,6 +534,9 @@ def create_filesystem_service(self, _endpoint):
def create_command_service(self, _endpoint):
return _Noop()
+ def create_pty_service(self, _endpoint):
+ return _Noop()
+
def create_health_service(self, _endpoint):
return _Noop()
diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml
index 57feabb92..d4d55291d 100644
--- a/specs/execd-api.yaml
+++ b/specs/execd-api.yaml
@@ -43,6 +43,8 @@ tags:
description: File and directory operations
- name: Metric
description: System resource monitoring and metrics
+ - name: PTY
+ description: Interactive pseudo-terminal session lifecycle
paths:
/ping:
@@ -1080,6 +1082,98 @@ paths:
"500":
$ref: "#/components/responses/InternalServerError"
+ /pty:
+ post:
+ summary: Create PTY session (create_pty_session)
+ description: |
+ Creates a new interactive pseudo-terminal session and returns a session ID. The shell does
+ not start until the first WebSocket attaches to `/pty/{sessionId}/ws` (the interactive
+ channel is a WebSocket and is intentionally not modelled here). Request body is optional.
+ operationId: createPtySession
+ tags:
+ - PTY
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreatePtySessionRequest"
+ examples:
+ default:
+ summary: Default (empty body allowed)
+ value: {}
+ with_cwd:
+ summary: With working directory
+ value:
+ cwd: /workspace
+ responses:
+ "201":
+ description: PTY session created successfully
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreatePtySessionResponse"
+ "400":
+ $ref: "#/components/responses/BadRequest"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+
+ /pty/{sessionId}:
+ get:
+ summary: Get PTY session status (get_pty_session)
+ description: Returns the status of a PTY session, including the output offset usable for replay.
+ operationId: getPtySession
+ tags:
+ - PTY
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ description: Session ID returned by create_pty_session
+ schema:
+ type: string
+ example: pty-abc123
+ responses:
+ "200":
+ description: PTY session status
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PtySessionStatusResponse"
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+ delete:
+ summary: Delete PTY session (delete_pty_session)
+ description: |
+ Tears down a PTY session on the server side, terminating the underlying shell process.
+ Returns 200 on success (the execd controller responds with an empty success body).
+ operationId: deletePtySession
+ tags:
+ - PTY
+ parameters:
+ - name: sessionId
+ in: path
+ required: true
+ description: Session ID to delete
+ schema:
+ type: string
+ example: pty-abc123
+ responses:
+ "200":
+ description: PTY session deleted successfully
+ "404":
+ $ref: "#/components/responses/NotFound"
+ "501":
+ $ref: "#/components/responses/NotImplemented"
+ "500":
+ $ref: "#/components/responses/InternalServerError"
+
components:
securitySchemes:
AccessToken:
@@ -1091,6 +1185,44 @@ components:
with a valid token. The token is configured during server initialization.
schemas:
+ CreatePtySessionRequest:
+ type: object
+ description: Request to create a PTY session (optional body; empty treated as defaults)
+ properties:
+ cwd:
+ type: string
+ description: Working directory for the shell
+ example: /workspace
+ command:
+ type: string
+ description: Command to run instead of the default login shell
+ CreatePtySessionResponse:
+ type: object
+ required:
+ - session_id
+ properties:
+ session_id:
+ type: string
+ description: Server-assigned identifier of the PTY session
+ example: pty-abc123
+ PtySessionStatusResponse:
+ type: object
+ required:
+ - session_id
+ - running
+ - output_offset
+ properties:
+ session_id:
+ type: string
+ description: Identifier of the PTY session
+ example: pty-abc123
+ running:
+ type: boolean
+ description: Whether the underlying shell process is alive
+ output_offset:
+ type: integer
+ format: int64
+ description: Byte offset of buffered output; pass as `since` on reconnect to replay scrollback
CreateSessionRequest:
type: object
description: Request to create a bash session (optional body; empty treated as defaults)
@@ -1518,3 +1650,13 @@ components:
example:
code: RUNTIME_ERROR
message: "error running code execution"
+
+ NotImplemented:
+ description: Operation not supported on this platform (e.g. PTY on Windows)
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ example:
+ code: NOT_SUPPORTED
+ message: "PTY is not supported on this platform"