Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +98,7 @@ class Sandbox internal constructor(
private val customHealthCheck: ((sandbox: Sandbox) -> Boolean)? = null,
private val httpClientProvider: HttpClientProvider,
private val diagnosticsService: Diagnostics,
private val ptyService: Pty,
Comment thread
ferponse marked this conversation as resolved.
Outdated
) : AutoCloseable {
private val logger = LoggerFactory.getLogger(Sandbox::class.java)

Expand All @@ -118,6 +120,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 and builds the WebSocket URL
* used to stream them. PTY is only supported on Unix-like platforms.
*
* @return Service for PTY session management
*/
fun pty() = ptyService

/**
* Provides access to sandbox metrics and monitoring.
*
Expand Down Expand Up @@ -225,6 +237,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 =
Expand All @@ -249,6 +262,7 @@ class Sandbox internal constructor(
customHealthCheck = healthCheck,
httpClientProvider = httpClientProvider,
diagnosticsService = diagnosticsService,
ptyService = ptyService,
)

if (!skipHealthCheck) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* 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

/**
* Streaming mode for a PTY WebSocket connection.
*
* - [PTY]: a real pseudo-terminal (ANSI, `stty`, resize). Default.
* - [PIPE]: stdout/stderr are split into separate frames, without a TTY.
*/
enum class PtyMode {
PTY,
PIPE,
}

/**
* 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,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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.PtyMode
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 HTTP API ([createSession] / [getSession] / [deleteSession])
* and builds the WebSocket URL ([webSocketUrl]) that a client connects to in order to stream
* the interactive session. Driving the WebSocket itself (binary stdin/stdout frames, resize,
* takeover) is left to the caller, which can use any WebSocket client.
*
* 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? = null,
command: String? = null,
): PtySession
Comment thread
ferponse marked this conversation as resolved.

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

/**
* Builds the WebSocket URL used to attach to a PTY session.
*
* @param sessionId Identifier of the PTY session
* @param mode Streaming mode ([PtyMode.PTY] by default; [PtyMode.PIPE] adds `pty=0`)
* @param since Optional byte offset to replay buffered output from on reconnect
* @param takeover When true, evict the current holder (`takeover=1`) and attach to the same shell
* @return A `ws://` or `wss://` URL (scheme derived from the connection protocol)
*/
fun webSocketUrl(
sessionId: String,
mode: PtyMode = PtyMode.PTY,
since: Long? = null,
takeover: Boolean = false,
): String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/*
* 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.domain.exceptions.SandboxApiException
import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtyMode
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.jsonParser
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.converter.toSandboxException
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import okhttp3.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.slf4j.LoggerFactory

/**
* Implementation of [Pty] backed by execd's PTY HTTP endpoints.
*
* execd's PTY routes are not part of the OpenAPI specs (the interactive channel is a
* WebSocket), so this adapter is handwritten transport over the shared OkHttp client,
Comment thread
ferponse marked this conversation as resolved.
Outdated
* following the same endpoint/header wiring as the other execd adapters.
*/
internal class PtyAdapter(
private val httpClientProvider: HttpClientProvider,
private val execdEndpoint: SandboxEndpoint,
) : Pty {
companion object {
private const val PTY_PATH = "/pty"
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
}

private val logger = LoggerFactory.getLogger(PtyAdapter::class.java)
private val execdBaseUrl = "${httpClientProvider.config.protocol}://${execdEndpoint.endpoint}"
Comment thread
ferponse marked this conversation as resolved.
Outdated
Comment thread
ferponse marked this conversation as resolved.
Outdated
private val execdApiClient =
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 {
try {
val body = jsonParser.encodeToString(CreatePtySessionRequest(cwd, command))
val request =
Request.Builder()
.url("$execdBaseUrl$PTY_PATH")
Comment thread
ferponse marked this conversation as resolved.
.post(body.toRequestBody(JSON_MEDIA_TYPE))
Comment thread
ferponse marked this conversation as resolved.
.headers(execdEndpoint.headers.toHeaders())
.build()
execdApiClient.newCall(request).execute().use { response ->
val payload = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw SandboxApiException(
message = "Failed to create PTY session. Status code: ${response.code}, Body: $payload",
statusCode = response.code,
)
Comment thread
ferponse marked this conversation as resolved.
Outdated
}
val parsed = jsonParser.decodeFromString<CreatePtySessionResponse>(payload)
return PtySession(parsed.sessionId)
}
} catch (e: Exception) {
logger.error("Failed to create PTY session", e)
throw e.toSandboxException()
}
}

override fun getSession(sessionId: String): PtySessionStatus {
try {
val request =
Request.Builder()
.url("$execdBaseUrl$PTY_PATH/$sessionId")
.get()
.headers(execdEndpoint.headers.toHeaders())
.build()
execdApiClient.newCall(request).execute().use { response ->
val payload = response.body?.string().orEmpty()
if (!response.isSuccessful) {
throw SandboxApiException(
message = "Failed to get PTY session $sessionId. Status code: ${response.code}, Body: $payload",
statusCode = response.code,
)
}
val parsed = jsonParser.decodeFromString<PtySessionStatusResponse>(payload)
return PtySessionStatus(parsed.sessionId, parsed.running, parsed.outputOffset)
}
} catch (e: Exception) {
logger.error("Failed to get PTY session {}", sessionId, e)
throw e.toSandboxException()
}
}

override fun deleteSession(sessionId: String) {
try {
val request =
Request.Builder()
.url("$execdBaseUrl$PTY_PATH/$sessionId")
.delete()
.headers(execdEndpoint.headers.toHeaders())
.build()
execdApiClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val payload = response.body?.string().orEmpty()
throw SandboxApiException(
message = "Failed to delete PTY session $sessionId. Status code: ${response.code}, Body: $payload",
statusCode = response.code,
)
}
}
} catch (e: Exception) {
logger.error("Failed to delete PTY session {}", sessionId, e)
throw e.toSandboxException()
}
}

override fun webSocketUrl(
sessionId: String,
mode: PtyMode,
since: Long?,
takeover: Boolean,
): String {
val scheme = if (httpClientProvider.config.protocol.equals("https", ignoreCase = true)) "wss" else "ws"
val params =
buildList {
if (mode == PtyMode.PIPE) add("pty=0")
if (since != null) add("since=$since")
if (takeover) add("takeover=1")
}
val query = if (params.isEmpty()) "" else "?" + params.joinToString("&")
return "$scheme://${execdEndpoint.endpoint}$PTY_PATH/$sessionId/ws$query"
Comment thread
ferponse marked this conversation as resolved.
Outdated
}

@Serializable
private data class CreatePtySessionRequest(
val cwd: String? = null,
val command: String? = null,
)

@Serializable
private data class CreatePtySessionResponse(
@SerialName("session_id") val sessionId: String,
)

@Serializable
private data class PtySessionStatusResponse(
@SerialName("session_id") val sessionId: String,
@SerialName("running") val running: Boolean = false,
@SerialName("output_offset") val outputOffset: Long = 0,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ 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
import com.alibaba.opensandbox.sandbox.infrastructure.adapters.service.EgressAdapter
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

/**
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading