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"