From 8cd30f2595aacbe4f947efc59041c7f55fd4a52d Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 01:19:06 +0200 Subject: [PATCH 1/7] feat(execd): add PTY session REST routes to the spec and regenerate clients execd already serves the interactive PTY session lifecycle, but the routes were never in the source contract, so SDK clients could drift from the server. Add them to the source of truth and regenerate the generated clients. - spec: add POST /pty, GET /pty/{sessionId}, DELETE /pty/{sessionId} to specs/execd-api.yaml, matching execd's real behavior (DELETE returns 200; all three document the 501 NOT_SUPPORTED platform response). The interactive /pty/{id}/ws channel stays a WebSocket and is intentionally not modelled. - python: regenerate the execd client (openapi-python-client) -> adds the pty API module and Create/Status request/response models. - javascript: regenerate execd.ts (openapi-typescript) -> adds the /pty path and schema types. Kotlin regenerates its execd client from the spec at build time. The Go and C# execd clients are hand-written (not generated from this spec) and can add PTY as a follow-up feature when those SDKs choose to expose it. Co-authored-by: Atenea Agent --- sdks/sandbox/javascript/src/api/execd.ts | 187 +++++++++++++++ .../opensandbox/api/execd/api/pty/__init__.py | 17 ++ .../api/execd/api/pty/create_pty_session.py | 213 ++++++++++++++++++ .../api/execd/api/pty/delete_pty_session.py | 192 ++++++++++++++++ .../api/execd/api/pty/get_pty_session.py | 194 ++++++++++++++++ .../opensandbox/api/execd/models/__init__.py | 6 + .../models/create_pty_session_request.py | 87 +++++++ .../models/create_pty_session_response.py | 77 +++++++ .../models/pty_session_status_response.py | 93 ++++++++ specs/execd-api.yaml | 142 ++++++++++++ 10 files changed, 1208 insertions(+) create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/api/pty/__init__.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/api/pty/create_pty_session.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/api/pty/delete_pty_session.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/api/pty/get_pty_session.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_request.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/models/create_pty_session_response.py create mode 100644 sdks/sandbox/python/src/opensandbox/api/execd/models/pty_session_status_response.py 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/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/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" From 98378ad153b6a819fcc11e304db01c24621066dd Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 01:41:35 +0200 Subject: [PATCH 2/7] feat(sdks/kotlin): add PTY session service backed by the generated PTYApi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose execd's PTY session lifecycle in the Kotlin SDK as `sandbox.pty()`, implemented on top of the OpenAPI-generated PTYApi (this PR adds /pty to the execd spec): createSession / getSession / deleteSession. No handwritten transport — request/response mapping and error handling go through the generated client and the shared exception converter, like the other execd adapters. The PTY service is wired after construction via an internal bindPtyService() so the public Sandbox constructor signature is unchanged. The interactive WebSocket attach helper (ws/wss URL + handshake headers) is intentionally out of scope here and will follow in a branch stacked on this one. Co-authored-by: Atenea Agent --- .../alibaba/opensandbox/sandbox/Sandbox.kt | 22 +++ .../domain/models/execd/pty/PtyModels.kt | 43 ++++++ .../sandbox/domain/services/Pty.kt | 68 +++++++++ .../adapters/service/PtyAdapter.kt | 85 ++++++++++++ .../infrastructure/factory/AdapterFactory.kt | 6 + .../opensandbox/sandbox/SandboxTest.kt | 10 ++ .../adapters/service/PtyAdapterTest.kt | 129 ++++++++++++++++++ 7 files changed, 363 insertions(+) create mode 100644 sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/pty/PtyModels.kt create mode 100644 sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Pty.kt create mode 100644 sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapter.kt create mode 100644 sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapterTest.kt 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")) + } +} From 8fb390263a5ec7c54e06fe277364a2a5b5db949c Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 02:02:36 +0200 Subject: [PATCH 3/7] feat(sdks/python): add PTY session service backed by the generated PTY client Expose execd's PTY session lifecycle in the Python SDK as `sandbox.pty` (async and sync variants): create_session / get_session / delete_session, implemented on the openapi-python-client generated PTY API. Adds the Pty / PtySync service protocols, PtyAdapter / PtyAdapterSync, PtySession / PtySessionStatus domain models, factory wiring and the Sandbox accessor. Co-authored-by: Atenea Agent --- .../src/opensandbox/adapters/__init__.py | 2 + .../src/opensandbox/adapters/factory.py | 13 ++ .../src/opensandbox/adapters/pty_adapter.py | 133 ++++++++++++++++++ .../python/src/opensandbox/models/execd.py | 24 ++++ .../sandbox/python/src/opensandbox/sandbox.py | 18 +++ .../src/opensandbox/services/__init__.py | 2 + .../python/src/opensandbox/services/pty.py | 72 ++++++++++ .../src/opensandbox/sync/adapters/__init__.py | 2 + .../src/opensandbox/sync/adapters/factory.py | 5 + .../opensandbox/sync/adapters/pty_adapter.py | 110 +++++++++++++++ .../python/src/opensandbox/sync/sandbox.py | 18 +++ .../src/opensandbox/sync/services/__init__.py | 2 + .../src/opensandbox/sync/services/pty.py | 50 +++++++ .../python/tests/test_pty_service_adapter.py | 101 +++++++++++++ .../tests/test_sandbox_business_logic.py | 15 ++ .../tests/test_sandbox_sync_business_logic.py | 15 ++ 16 files changed, 582 insertions(+) create mode 100644 sdks/sandbox/python/src/opensandbox/adapters/pty_adapter.py create mode 100644 sdks/sandbox/python/src/opensandbox/services/pty.py create mode 100644 sdks/sandbox/python/src/opensandbox/sync/adapters/pty_adapter.py create mode 100644 sdks/sandbox/python/src/opensandbox/sync/services/pty.py create mode 100644 sdks/sandbox/python/tests/test_pty_service_adapter.py 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/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() From 14c0ca30e2b3d208927a8cc3ccc05d92230ef19e Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 02:06:06 +0200 Subject: [PATCH 4/7] feat(sdks/go): add PTY session lifecycle to the execd client and Sandbox Add CreatePtySession / GetPtySession / DeletePtySession to the hand-written execd client and expose them on the Sandbox, mirroring the existing session helpers. The PtySession / PtySessionStatus types map execd's /pty REST responses (session_id / running / output_offset). The interactive WebSocket channel is driven separately and is out of scope here. Co-authored-by: Atenea Agent --- sdks/sandbox/go/sandbox_pty.go | 94 +++++++++++++++++++++++++++++ sdks/sandbox/go/sandbox_pty_test.go | 66 ++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 sdks/sandbox/go/sandbox_pty.go create mode 100644 sdks/sandbox/go/sandbox_pty_test.go 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) +} From e2a31a6282ba5b1f7e4cbc9bc6c54ba952c4d5ca Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 02:28:45 +0200 Subject: [PATCH 5/7] feat(sdks/js,csharp): add PTY session service to the JS and C# SDKs Expose execd's PTY session lifecycle (create / status / delete) in the remaining SDKs, on top of the clients generated/hand-written from the /pty spec: - JavaScript: ExecdPty service + PtyAdapter (openapi-fetch over execd.ts), wired into the execd stack and exposed as `sandbox.pty`. - C#: IExecdPty + PtyAdapter (HttpClientWrapper), wired into ExecdStack and exposed as `Sandbox.Pty`. Adds PtySession / PtySessionStatus models and unit tests in both SDKs. DELETE is read as a stream in JS to avoid JSON-parsing its empty 200 body. Co-authored-by: Atenea Agent --- .../src/OpenSandbox/Adapters/PtyAdapter.cs | 65 +++++++++++ .../Factory/DefaultAdapterFactory.cs | 4 +- .../OpenSandbox/Factory/IAdapterFactory.cs | 5 + .../csharp/src/OpenSandbox/Models/Pty.cs | 54 ++++++++++ .../sandbox/csharp/src/OpenSandbox/Sandbox.cs | 9 ++ .../src/OpenSandbox/Services/IExecdPty.cs | 59 ++++++++++ .../OpenSandbox.Tests/PtyAdapterTests.cs | 102 ++++++++++++++++++ .../SandboxEgressLifecycleTests.cs | 3 +- .../SandboxReadinessDiagnosticsTests.cs | 3 +- .../javascript/src/adapters/ptyAdapter.ts | 51 +++++++++ .../javascript/src/factory/adapterFactory.ts | 2 + .../src/factory/defaultAdapterFactory.ts | 3 + sdks/sandbox/javascript/src/internal.ts | 1 + sdks/sandbox/javascript/src/models/execd.ts | 22 +++- sdks/sandbox/javascript/src/sandbox.ts | 13 ++- .../javascript/src/services/execdPty.ts | 31 ++++++ sdks/sandbox/javascript/tests/pty.test.mjs | 65 +++++++++++ 17 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 sdks/sandbox/csharp/src/OpenSandbox/Adapters/PtyAdapter.cs create mode 100644 sdks/sandbox/csharp/src/OpenSandbox/Models/Pty.cs create mode 100644 sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdPty.cs create mode 100644 sdks/sandbox/csharp/tests/OpenSandbox.Tests/PtyAdapterTests.cs create mode 100644 sdks/sandbox/javascript/src/adapters/ptyAdapter.ts create mode 100644 sdks/sandbox/javascript/src/services/execdPty.ts create mode 100644 sdks/sandbox/javascript/tests/pty.test.mjs 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..3b8d9a674 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs @@ -112,6 +112,11 @@ public class ExecdStack /// Gets the metrics service. /// public required IExecdMetrics Metrics { get; init; } + + /// + /// Gets the interactive PTY session service. + /// + public required 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..decd04d7c 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; _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); 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/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs index 0182ec05d..e9689e511 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -248,7 +248,8 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options) Commands = new Mock(MockBehavior.Strict).Object, Files = new StubFiles(), Health = new StubHealth(), - Metrics = new StubMetrics() + Metrics = new StubMetrics(), + Pty = new Mock(MockBehavior.Strict).Object }; } diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs index cccaaffa8..b8f009876 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs @@ -126,7 +126,8 @@ private static async Task CreateSandboxForReadinessTestAsync( Commands = Mock.Of(), Files = Mock.Of(), Health = healthMock.Object, - Metrics = Mock.Of() + Metrics = Mock.Of(), + Pty = Mock.Of() }); adapterFactoryMock diff --git a/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts new file mode 100644 index 000000000..886c61127 --- /dev/null +++ b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts @@ -0,0 +1,51 @@ +// 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"; + +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 { + // DELETE returns 200 with an empty body; avoid JSON-parsing the empty response. + const { error, response } = await this.client.DELETE("/pty/{sessionId}", { + params: { path: { sessionId } }, + parseAs: "stream", + }); + throwOnOpenApiFetchError({ error, response }, "Delete PTY session failed"); + } +} diff --git a/sdks/sandbox/javascript/src/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts index 0dd9e935f..632a50b19 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,7 @@ export interface ExecdStack { files: SandboxFiles; health: ExecdHealth; metrics: ExecdMetrics; + 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/internal.ts b/sdks/sandbox/javascript/src/internal.ts index cdaff43c5..2b554cab0 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 } 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..e6bcbbf8e 100644 --- a/sdks/sandbox/javascript/src/sandbox.ts +++ b/sdks/sandbox/javascript/src/sandbox.ts @@ -31,6 +31,7 @@ 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 type { CreateSandboxRequest, CredentialProxyConfig, @@ -240,6 +241,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 +276,7 @@ export class Sandbox { files: SandboxFiles; health: ExecdHealth; metrics: ExecdMetrics; + pty: ExecdPty; egress: Egress; credentialVault?: CredentialVault; }) { @@ -294,6 +300,7 @@ export class Sandbox { this.files = opts.files; this.health = opts.health; this.metrics = opts.metrics; + this.pty = opts.pty; this.credentialVault = credentialVault; } @@ -395,7 +402,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 +425,7 @@ export class Sandbox { files, health, metrics, + pty, egress, credentialVault, }); @@ -480,7 +488,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 +511,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..bf17f0a63 --- /dev/null +++ b/sdks/sandbox/javascript/tests/pty.test.mjs @@ -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. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PtyAdapter, 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"); + return new Response("", { status: 200 }); + }, + })); + + await pty.deleteSession("sess-123"); + assert.equal(method, "DELETE"); +}); From 9908a47b3f38ec853547ae864764743c8358d7a2 Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 02:49:52 +0200 Subject: [PATCH 6/7] fix(sdks/js,csharp): keep PTY additive for custom adapter factories Address Codex review on #1054: - Make the execd stack's `pty`/`Pty` member optional in the public `AdapterFactory` / `IAdapterFactory` contract, and install an unavailable-PTY fallback in `Sandbox` when a custom factory omits it (mirrors the existing unavailable-credential-vault fallback). Existing factories and test doubles keep compiling and running; `sandbox.pty` stays defined and fails loudly only on use. - JS: stop forcing the PTY DELETE response through the stream parser. The empty 200 body (Content-Length: 0) is already skipped by openapi-fetch, while error responses (404 CONTEXT_NOT_FOUND, 501 NOT_SUPPORTED) keep their JSON code/message for throwOnOpenApiFetchError to surface. Tests cover the JSON-error path, the empty-success path, and the unavailable-PTY fallback; the C# stack tests no longer set Pty, proving the change is additive. Co-authored-by: Atenea Agent --- .../OpenSandbox/Factory/IAdapterFactory.cs | 6 +++- .../sandbox/csharp/src/OpenSandbox/Sandbox.cs | 26 +++++++++++++-- .../SandboxEgressLifecycleTests.cs | 3 +- .../SandboxReadinessDiagnosticsTests.cs | 3 +- .../javascript/src/adapters/ptyAdapter.ts | 33 +++++++++++++++++-- .../javascript/src/factory/adapterFactory.ts | 6 +++- sdks/sandbox/javascript/src/internal.ts | 2 +- sdks/sandbox/javascript/src/sandbox.ts | 5 +-- sdks/sandbox/javascript/tests/pty.test.mjs | 31 +++++++++++++++-- 9 files changed, 100 insertions(+), 15 deletions(-) diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs index 3b8d9a674..c7d1bec28 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Factory/IAdapterFactory.cs @@ -116,7 +116,11 @@ public class ExecdStack /// /// Gets the interactive PTY session service. /// - public required IExecdPty Pty { get; init; } + /// + /// 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/Sandbox.cs b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs index decd04d7c..3aa47c767 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Sandbox.cs @@ -101,7 +101,7 @@ private Sandbox( ISandboxFiles files, IExecdHealth health, IExecdMetrics metrics, - IExecdPty pty, + IExecdPty? pty, IEgress egress, ICredentialVault? credentialVault) { @@ -118,7 +118,7 @@ private Sandbox( Files = files; Health = health; Metrics = metrics; - Pty = pty; + Pty = pty ?? new UnavailablePtyService(); _egress = egress; CredentialVault = credentialVault ?? egress as ICredentialVault @@ -918,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/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs index e9689e511..0182ec05d 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxEgressLifecycleTests.cs @@ -248,8 +248,7 @@ public ExecdStack CreateExecdStack(CreateExecdStackOptions options) Commands = new Mock(MockBehavior.Strict).Object, Files = new StubFiles(), Health = new StubHealth(), - Metrics = new StubMetrics(), - Pty = new Mock(MockBehavior.Strict).Object + Metrics = new StubMetrics() }; } diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs index b8f009876..cccaaffa8 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxReadinessDiagnosticsTests.cs @@ -126,8 +126,7 @@ private static async Task CreateSandboxForReadinessTestAsync( Commands = Mock.Of(), Files = Mock.Of(), Health = healthMock.Object, - Metrics = Mock.Of(), - Pty = Mock.Of() + Metrics = Mock.Of() }); adapterFactoryMock diff --git a/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts index 886c61127..6964f7946 100644 --- a/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/ptyAdapter.ts @@ -16,6 +16,7 @@ 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) {} @@ -41,11 +42,39 @@ export class PtyAdapter implements ExecdPty { } async deleteSession(sessionId: string): Promise { - // DELETE returns 200 with an empty body; avoid JSON-parsing the empty response. + // 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 } }, - parseAs: "stream", }); 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/factory/adapterFactory.ts b/sdks/sandbox/javascript/src/factory/adapterFactory.ts index 632a50b19..b6bced15f 100644 --- a/sdks/sandbox/javascript/src/factory/adapterFactory.ts +++ b/sdks/sandbox/javascript/src/factory/adapterFactory.ts @@ -41,7 +41,11 @@ export interface ExecdStack { files: SandboxFiles; health: ExecdHealth; metrics: ExecdMetrics; - pty: ExecdPty; + /** + * 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/internal.ts b/sdks/sandbox/javascript/src/internal.ts index 2b554cab0..39eb9f651 100644 --- a/sdks/sandbox/javascript/src/internal.ts +++ b/sdks/sandbox/javascript/src/internal.ts @@ -42,4 +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 } from "./adapters/ptyAdapter.js"; +export { PtyAdapter, UnavailablePtyAdapter } from "./adapters/ptyAdapter.js"; diff --git a/sdks/sandbox/javascript/src/sandbox.ts b/sdks/sandbox/javascript/src/sandbox.ts index e6bcbbf8e..40c11024f 100644 --- a/sdks/sandbox/javascript/src/sandbox.ts +++ b/sdks/sandbox/javascript/src/sandbox.ts @@ -32,6 +32,7 @@ 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, @@ -276,7 +277,7 @@ export class Sandbox { files: SandboxFiles; health: ExecdHealth; metrics: ExecdMetrics; - pty: ExecdPty; + pty?: ExecdPty; egress: Egress; credentialVault?: CredentialVault; }) { @@ -300,7 +301,7 @@ export class Sandbox { this.files = opts.files; this.health = opts.health; this.metrics = opts.metrics; - this.pty = opts.pty; + this.pty = opts.pty ?? new UnavailablePtyAdapter(); this.credentialVault = credentialVault; } diff --git a/sdks/sandbox/javascript/tests/pty.test.mjs b/sdks/sandbox/javascript/tests/pty.test.mjs index bf17f0a63..57c23d331 100644 --- a/sdks/sandbox/javascript/tests/pty.test.mjs +++ b/sdks/sandbox/javascript/tests/pty.test.mjs @@ -15,7 +15,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { PtyAdapter, createExecdClient } from "../dist/internal.js"; +import { PtyAdapter, UnavailablePtyAdapter, createExecdClient } from "../dist/internal.js"; test("PtyAdapter.createSession posts to /pty and maps session_id", async () => { const pty = new PtyAdapter(createExecdClient({ @@ -56,10 +56,37 @@ test("PtyAdapter.deleteSession issues a DELETE", async () => { async fetch(request) { method = request.method; assert.equal(new URL(request.url).pathname, "/pty/sess-123"); - return new Response("", { status: 200 }); + // 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; + }); +}); From 4b66e1d52c1dc63e873d09f75fb748abf52060c8 Mon Sep 17 00:00:00 2001 From: Ferran Pons Serra Date: Sun, 14 Jun 2026 03:03:38 +0200 Subject: [PATCH 7/7] feat(sdks/js): export PTY public types from the root entrypoint `sandbox.pty` is part of the root public API, so export `ExecdPty`, `PtySession`, and `PtySessionStatus` alongside `ExecdCommands` instead of leaving them reachable only via inference or internal paths. Co-authored-by: Atenea Agent --- sdks/sandbox/javascript/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) 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,