Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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.
*
Expand All @@ -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 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 +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 =
Expand All @@ -250,6 +271,7 @@ class Sandbox internal constructor(
httpClientProvider = httpClientProvider,
diagnosticsService = diagnosticsService,
)
sandbox.bindPtyService(ptyService)

if (!skipHealthCheck) {
sandbox.checkReady(timeout, healthCheckPollingInterval)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 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,
)

/**
* WebSocket target for attaching to a PTY session.
*
* @property url The `ws://` or `wss://` URL to open
* @property headers Routing/auth headers that must be sent on the WebSocket handshake
* (header-mode ingress and secure-access endpoints require them)
*/
class PtyWebSocket(
val url: String,
val headers: Map<String, String>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* 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
import com.alibaba.opensandbox.sandbox.domain.models.execd.pty.PtyWebSocket

/**
* 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 resolves the WebSocket target ([webSocket]) 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.
*
* 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
Comment thread
ferponse marked this conversation as resolved.

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

/**
* Resolves the WebSocket target used to attach to a PTY session.
*
* The returned [PtyWebSocket] carries both the `ws`/`wss` URL and the routing/auth headers
* resolved for the sandbox endpoint; callers must send those headers on the WebSocket
* handshake (header-mode ingress and secure-access endpoints rely on them), just as the REST
* calls in this service do.
*
* @param sessionId Identifier of the PTY session
* @param mode Streaming mode ([PtyMode.PTY]; [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 The WebSocket URL together with the headers to send on the handshake
*/
fun webSocket(
sessionId: String,
mode: PtyMode,
since: Long?,
takeover: Boolean,
): PtyWebSocket

/** Resolves the WebSocket target for the given mode, without replay or takeover. */
fun webSocket(
sessionId: String,
mode: PtyMode,
): PtyWebSocket = webSocket(sessionId, mode, null, false)

/** Resolves the WebSocket target in PTY mode, without replay or takeover. */
fun webSocket(sessionId: String): PtyWebSocket = webSocket(sessionId, PtyMode.PTY, null, false)
}
Loading
Loading