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..e1a363e75 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 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. * @@ -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..0be2a55ea --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/execd/pty/PtyModels.kt @@ -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, +) 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..f2ae00eda --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/services/Pty.kt @@ -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 + + /** 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) +} 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..e37c1b3b8 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapter.kt @@ -0,0 +1,208 @@ +/* + * 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.exceptions.SandboxError +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 +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.parseSandboxError +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 okhttp3.Response +import org.slf4j.LoggerFactory + +/** + * Implementation of [Pty] backed by execd's PTY HTTP endpoints. + * + * execd's interactive PTY channel is a WebSocket and cannot be modelled by the OpenAPI + * generator, so this adapter is handwritten transport over the shared OkHttp client, following + * the same endpoint/header wiring, scheme resolution and error mapping 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 const val REQUEST_ID_HEADER = "X-Request-ID" + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + } + + private val logger = LoggerFactory.getLogger(PtyAdapter::class.java) + + // Scheme for the resolved execd endpoint: + // - server-proxy endpoints are reached back through the lifecycle server, so they inherit its + // scheme (honoring an https domain even when `protocol` is left at its default); + // - direct raw endpoints (pod IP / host-mapped port) serve plain HTTP, like the other execd + // adapters (e.g. CommandsAdapter), and must not be forced to https from the lifecycle domain. + private val httpScheme = resolveHttpScheme() + private val execdBaseUrl = "$httpScheme://${execdEndpoint.endpoint}" + 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") + .post(body.toRequestBody(JSON_MEDIA_TYPE)) + .headers(execdEndpoint.headers.toHeaders()) + .build() + execdApiClient.newCall(request).execute().use { response -> + val payload = response.body?.string().orEmpty() + if (!response.isSuccessful) { + throw apiException("Failed to create PTY session", response, payload) + } + val parsed = jsonParser.decodeFromString(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 apiException("Failed to get PTY session $sessionId", response, payload) + } + val parsed = jsonParser.decodeFromString(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 apiException("Failed to delete PTY session $sessionId", response, payload) + } + } + } catch (e: Exception) { + logger.error("Failed to delete PTY session {}", sessionId, e) + throw e.toSandboxException() + } + } + + override fun webSocket( + sessionId: String, + mode: PtyMode, + since: Long?, + takeover: Boolean, + ): PtyWebSocket { + val scheme = if (httpScheme == "https") "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("&") + val url = "$scheme://${execdEndpoint.endpoint}$PTY_PATH/$sessionId/ws$query" + // Send the same headers the REST/HTTP calls send on the handshake: the user-configured + // ConnectionConfig.headers (applied by HttpClientProvider) plus the endpoint routing/auth + // headers, with endpoint headers taking precedence on conflicts. + val headers = httpClientProvider.config.headers + execdEndpoint.headers + return PtyWebSocket(url, headers) + } + + private fun resolveHttpScheme(): String { + val config = httpClientProvider.config + // Direct raw endpoints serve plain HTTP; only proxied endpoints inherit the lifecycle scheme. + if (!config.useServerProxy) return config.protocol + val domain = config.getDomain() + return when { + domain.startsWith("https://", ignoreCase = true) -> "https" + domain.startsWith("http://", ignoreCase = true) -> "http" + else -> config.protocol + } + } + + private fun apiException( + action: String, + response: Response, + payload: String, + ): SandboxApiException = + SandboxApiException( + message = "$action. Status code: ${response.code}, Body: $payload", + statusCode = response.code, + error = parseSandboxError(payload) ?: SandboxError(SandboxError.UNEXPECTED_RESPONSE), + requestId = response.header(REQUEST_ID_HEADER), + ) + + @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, + ) +} 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..f30652f8d --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/PtyAdapterTest.kt @@ -0,0 +1,237 @@ +/* + * 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.execd.pty.PtyMode +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.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PtyAdapterTest { + private lateinit var mockWebServer: MockWebServer + private lateinit var httpClientProvider: HttpClientProvider + private lateinit var ptyAdapter: PtyAdapter + private lateinit var endpoint: SandboxEndpoint + + @BeforeEach + fun setUp() { + mockWebServer = MockWebServer() + mockWebServer.start() + val host = mockWebServer.hostName + val port = mockWebServer.port + 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(204)) + + ptyAdapter.deleteSession("sess-123") + + val recorded = mockWebServer.takeRequest() + assertEquals("/pty/sess-123", recorded.path) + assertEquals("DELETE", recorded.method) + } + + @Test + fun `createSession should throw SandboxApiException on error status`() { + mockWebServer.enqueue(MockResponse().setResponseCode(500).setBody("boom")) + + val ex = + assertThrows(SandboxApiException::class.java) { + ptyAdapter.createSession() + } + assertEquals(500, ex.statusCode) + } + + @Test + fun `webSocket should build a ws url with mode since and takeover params`() { + val base = ptyAdapter.webSocket("sess-123") + assertEquals("ws://${endpoint.endpoint}/pty/sess-123/ws", base.url) + + val full = + ptyAdapter.webSocket( + "sess-123", + mode = PtyMode.PIPE, + since = 4096, + takeover = true, + ) + assertEquals("ws://${endpoint.endpoint}/pty/sess-123/ws?pty=0&since=4096&takeover=1", full.url) + } + + @Test + fun `webSocket should use wss when protocol is https`() { + val config = + ConnectionConfig.builder() + .domain(endpoint.endpoint) + .protocol("https") + .build() + val secureAdapter = PtyAdapter(HttpClientProvider(config), endpoint) + + val target = secureAdapter.webSocket("sess-123") + + assertTrue(target.url.startsWith("wss://"), "Expected wss scheme, got: ${target.url}") + } + + @Test + fun `webSocket should inherit https from the domain for server-proxy endpoints`() { + val config = + ConnectionConfig.builder() + .domain("https://${endpoint.endpoint}") + .useServerProxy(true) + .build() + val adapter = PtyAdapter(HttpClientProvider(config), endpoint) + + assertTrue(adapter.webSocket("sess-123").url.startsWith("wss://")) + } + + @Test + fun `webSocket should stay ws for direct endpoints even with an https domain`() { + // Direct (non server-proxy) raw endpoints serve plain HTTP regardless of the lifecycle domain. + val config = + ConnectionConfig.builder() + .domain("https://${endpoint.endpoint}") + .useServerProxy(false) + .build() + val adapter = PtyAdapter(HttpClientProvider(config), endpoint) + + assertTrue(adapter.webSocket("sess-123").url.startsWith("ws://")) + } + + @Test + fun `webSocket should carry the endpoint routing and auth headers`() { + val headers = mapOf("OpenSandbox-Ingress-To" to "sandbox-1-44772", "X-Auth" to "token") + val adapter = PtyAdapter(httpClientProvider, SandboxEndpoint(endpoint.endpoint, headers)) + + val target = adapter.webSocket("sess-123") + + assertEquals(headers, target.headers) + } + + @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")) + assertFalse(recorded.getHeader("X-Test-Header").isNullOrEmpty()) + } + + @Test + fun `webSocket should merge configured headers with endpoint headers taking precedence`() { + val config = + ConnectionConfig.builder() + .domain(endpoint.endpoint) + .protocol("http") + .headers(mapOf("X-Gateway" to "gw", "X-Shared" to "from-config")) + .build() + val endpointHeaders = mapOf("X-Route" to "r1", "X-Shared" to "from-endpoint") + val adapter = PtyAdapter(HttpClientProvider(config), SandboxEndpoint(endpoint.endpoint, endpointHeaders)) + + val target = adapter.webSocket("sess-123") + + assertEquals("gw", target.headers["X-Gateway"]) + assertEquals("r1", target.headers["X-Route"]) + // endpoint headers win on conflicts + assertEquals("from-endpoint", target.headers["X-Shared"]) + } + + @Test + fun `createSession should map structured execd errors with code and request id`() { + mockWebServer.enqueue( + MockResponse() + .setResponseCode(404) + .setHeader("X-Request-ID", "req-789") + .setBody("""{"code":"CONTEXT_NOT_FOUND","message":"no such session"}"""), + ) + + val ex = + assertThrows(SandboxApiException::class.java) { + ptyAdapter.getSession("sess-404") + } + + assertEquals(404, ex.statusCode) + assertEquals("CONTEXT_NOT_FOUND", ex.error.code) + assertEquals("req-789", ex.requestId) + } +}