Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
113 changes: 113 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Adapters/CommandsAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,119 @@ public async Task InterruptAsync(string sessionId, CancellationToken cancellatio
await _client.DeleteAsync("/command", queryParams, cancellationToken).ConfigureAwait(false);
}

public async Task<string> CreateSessionAsync(
CreateSessionOptions? options = null,
CancellationToken cancellationToken = default)
{
object? body = null;
if (!string.IsNullOrEmpty(options?.Cwd))
{
body = new { cwd = options.Cwd };
}

_logger.LogDebug("Creating bash session (cwd={Cwd})", options?.Cwd);
var response = await _client.PostAsync<CreateSessionResponse>("/session", body, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrEmpty(response?.SessionId))
{
throw new SandboxApiException(
message: "Create session returned empty session_id",
statusCode: 200,
error: new SandboxError(SandboxErrorCodes.UnexpectedResponse, "Create session returned empty session_id"));
}

return response.SessionId;
}

public async IAsyncEnumerable<ServerStreamEvent> RunInSessionStreamAsync(
string sessionId,
string code,
RunInSessionOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new InvalidArgumentException("sessionId cannot be empty");
}
if (string.IsNullOrWhiteSpace(code))
{
throw new InvalidArgumentException("code cannot be empty");
}

var path = $"/session/{Uri.EscapeDataString(sessionId)}/run";
var url = $"{_baseUrl}{path}";
var requestBody = new RunInSessionRequest
{
Code = code,
Cwd = options?.Cwd,
TimeoutMs = options?.TimeoutMs
};

var json = JsonSerializer.Serialize(requestBody, JsonOptions);
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};

request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));

foreach (var header in _headers)
{
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}

using var response = await _sseHttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);

await foreach (var ev in SseParser.ParseJsonEventStreamAsync<ServerStreamEvent>(response, "Run in session failed", cancellationToken).ConfigureAwait(false))
{
yield return ev;
}
}

public async Task<Execution> RunInSessionAsync(
string sessionId,
string code,
RunInSessionOptions? options = null,
ExecutionHandlers? handlers = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new InvalidArgumentException("sessionId cannot be empty");
}
if (string.IsNullOrWhiteSpace(code))
{
throw new InvalidArgumentException("code cannot be empty");
}

_logger.LogDebug("Running in session: {SessionId} (codeLength={CodeLength})", sessionId, code.Length);
var execution = new Execution();
var dispatcher = new ExecutionEventDispatcher(execution, handlers);

await foreach (var ev in RunInSessionStreamAsync(sessionId, code, options, cancellationToken).ConfigureAwait(false))
{
if (ev.Type == ServerStreamEventTypes.Init && string.IsNullOrEmpty(ev.Text) && !string.IsNullOrEmpty(execution.Id))
{
ev.Text = execution.Id;
}

await dispatcher.DispatchAsync(ev).ConfigureAwait(false);
}

return execution;
}

public async Task DeleteSessionAsync(string sessionId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
throw new InvalidArgumentException("sessionId cannot be empty");
}

_logger.LogDebug("Deleting bash session: {SessionId}", sessionId);
var path = $"/session/{Uri.EscapeDataString(sessionId)}";
await _client.DeleteAsync(path, cancellationToken: cancellationToken).ConfigureAwait(false);
}

public Task<CommandStatus> GetCommandStatusAsync(string executionId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(executionId))
Expand Down
56 changes: 56 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Models/Execd.cs
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,59 @@ public class PingResponse
{
// Empty response - ping just returns 200 OK
}

// --- Bash session API (create_session, run_in_session, delete_session) ---

/// <summary>
/// Options for creating a bash session.
/// </summary>
public class CreateSessionOptions
{
/// <summary>
/// Gets or sets the optional working directory for the session.
/// </summary>
public string? Cwd { get; set; }
}

/// <summary>
/// Response from create_session (POST /session).
/// </summary>
public class CreateSessionResponse
{
/// <summary>
/// Gets or sets the session ID for run_in_session and delete_session.
/// </summary>
[JsonPropertyName("session_id")]
public required string SessionId { get; set; }
}

/// <summary>
/// Options for running code in an existing bash session.
/// </summary>
public class RunInSessionOptions
{
/// <summary>
/// Gets or sets the optional working directory override for this run.
/// </summary>
public string? Cwd { get; set; }

/// <summary>
/// Gets or sets the maximum execution time in milliseconds.
/// </summary>
public long? TimeoutMs { get; set; }
}

/// <summary>
/// Request body for run_in_session (POST /session/{sessionId}/run).
/// </summary>
internal class RunInSessionRequest
{
[JsonPropertyName("code")]
public required string Code { get; set; }

[JsonPropertyName("cwd")]
public string? Cwd { get; set; }

[JsonPropertyName("timeout_ms")]
public long? TimeoutMs { get; set; }
}
41 changes: 41 additions & 0 deletions sdks/sandbox/csharp/src/OpenSandbox/Services/IExecdCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,45 @@ Task<CommandLogs> GetBackgroundCommandLogsAsync(
string executionId,
long? cursor = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Creates a new bash session with optional working directory.
/// The session maintains shell state (cwd, environment) across multiple <see cref="RunInSessionAsync"/> calls.
/// </summary>
/// <param name="options">Optional options (e.g. initial working directory).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The session ID for use with <see cref="RunInSessionAsync"/> and <see cref="DeleteSessionAsync"/>.</returns>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<string> CreateSessionAsync(
CreateSessionOptions? options = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Runs shell code in an existing bash session and returns the execution result (SSE consumed internally).
/// </summary>
/// <param name="sessionId">Session ID from <see cref="CreateSessionAsync"/>.</param>
/// <param name="code">Shell code to execute.</param>
/// <param name="options">Optional cwd and timeout for this run.</param>
/// <param name="handlers">Optional event handlers for real-time processing.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The execution result with stdout/stderr and completion status.</returns>
/// <exception cref="InvalidArgumentException">Thrown when <paramref name="sessionId"/> or <paramref name="code"/> is null or empty.</exception>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task<Execution> RunInSessionAsync(
string sessionId,
string code,
RunInSessionOptions? options = null,
ExecutionHandlers? handlers = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Deletes a bash session and releases resources.
/// </summary>
/// <param name="sessionId">Session ID to delete (from <see cref="CreateSessionAsync"/>).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <exception cref="InvalidArgumentException">Thrown when <paramref name="sessionId"/> is null or empty.</exception>
/// <exception cref="SandboxException">Thrown when the execd service request fails.</exception>
Task DeleteSessionAsync(
string sessionId,
CancellationToken cancellationToken = default);
}
151 changes: 149 additions & 2 deletions sdks/sandbox/csharp/tests/OpenSandbox.Tests/CommandsAdapterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task GetCommandStatusAsync_ShouldParseStatusResponse()
status.Content.Should().Be("sleep 1");
status.Running.Should().BeTrue();
status.ExitCode.Should().BeNull();
httpHandler.RequestUris.Should().Contain(uri => uri.EndsWith("/command/status/cmd-1", StringComparison.Ordinal));
httpHandler.RequestUris.Should().Contain(uri => uri.EndsWith("/command/status/cmd-1"));
}

[Fact]
Expand All @@ -68,7 +68,7 @@ public async Task GetBackgroundCommandLogsAsync_ShouldParseCursorHeader()

logs.Content.Should().Contain("line1");
logs.Cursor.Should().Be(42);
httpHandler.RequestUris.Should().Contain(uri => uri.Contains("/command/cmd-2/logs?cursor=10", StringComparison.Ordinal));
httpHandler.RequestUris.Should().Contain(uri => uri.Contains("/command/cmd-2/logs?cursor=10"));
}

[Fact]
Expand Down Expand Up @@ -181,6 +181,153 @@ await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("*uid is required when gid is provided*");
}

// --- Bash session API integration tests ---

[Fact]
public async Task CreateSessionAsync_ShouldReturnSessionId_WhenCwdProvided()
{
var handler = new StubHttpMessageHandler(async (request, _) =>
{
request.Method.Should().Be(HttpMethod.Post);
request.RequestUri!.ToString().Should().Contain("/session");
request.Content.Should().NotBeNull();
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("cwd").GetString().Should().Be("/tmp");

return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"session_id\":\"sess-abc123\"}", Encoding.UTF8, "application/json")
};
});
var adapter = CreateAdapter(handler);

var sessionId = await adapter.CreateSessionAsync(new CreateSessionOptions { Cwd = "/tmp" });

sessionId.Should().Be("sess-abc123");
handler.RequestUris.Should().Contain(uri => uri.EndsWith("/session"));
}

[Fact]
public async Task CreateSessionAsync_ShouldReturnSessionId_WhenNoOptions()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
request.Method.Should().Be(HttpMethod.Post);
request.RequestUri!.ToString().Should().Contain("/session");

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"session_id\":\"sess-default\"}", Encoding.UTF8, "application/json")
});
});
var adapter = CreateAdapter(handler);

var sessionId = await adapter.CreateSessionAsync();

sessionId.Should().Be("sess-default");
}

[Fact]
public async Task CreateSessionAsync_ShouldThrow_WhenResponseHasEmptySessionId()
{
var handler = new StubHttpMessageHandler((_, _) =>
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"session_id\":\"\"}", Encoding.UTF8, "application/json")
});
});
var adapter = CreateAdapter(handler);

var act = () => adapter.CreateSessionAsync();

await act.Should().ThrowAsync<SandboxApiException>()
.WithMessage("*empty session_id*");
}

[Fact]
public async Task RunInSessionAsync_ShouldSendCodeAndOptions()
{
var handler = new StubHttpMessageHandler(async (request, _) =>
{
request.Method.Should().Be(HttpMethod.Post);
request.RequestUri!.ToString().Should().Contain("/session/sess-1/run");
request.Content.Should().NotBeNull();
var body = await request.Content!.ReadAsStringAsync().ConfigureAwait(false);
using var doc = JsonDocument.Parse(body);
doc.RootElement.GetProperty("code").GetString().Should().Be("pwd");
doc.RootElement.GetProperty("cwd").GetString().Should().Be("/var");
doc.RootElement.GetProperty("timeout_ms").GetInt64().Should().Be(5000);

var sse = "data: {\"type\":\"stdout\",\"text\":\"/var\"}\ndata: {\"type\":\"execution_complete\"}\n";
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(sse, Encoding.UTF8, "text/event-stream")
};
});
var adapter = CreateAdapter(handler);

var run = await adapter.RunInSessionAsync(
"sess-1",
"pwd",
new RunInSessionOptions { Cwd = "/var", TimeoutMs = 5000 });

run.Should().NotBeNull();
run.Logs.Stdout.Should().ContainSingle(m => m.Text == "/var");
handler.RequestUris.Should().Contain(uri => uri.Contains("/session/sess-1/run"));
}

[Fact]
public async Task RunInSessionAsync_ShouldThrow_WhenSessionIdEmpty()
{
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));

var act = () => adapter.RunInSessionAsync("", "echo hi");

await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("*sessionId*");
}

[Fact]
public async Task RunInSessionAsync_ShouldThrow_WhenCodeEmpty()
{
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));

var act = () => adapter.RunInSessionAsync("sess-1", " ");

await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("*code*");
}

[Fact]
public async Task DeleteSessionAsync_ShouldCallDeleteEndpoint()
{
var handler = new StubHttpMessageHandler((request, _) =>
{
request.Method.Should().Be(HttpMethod.Delete);
request.RequestUri!.ToString().Should().Contain("/session/sess-to-delete");

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
});
var adapter = CreateAdapter(handler);

await adapter.DeleteSessionAsync("sess-to-delete");

handler.RequestUris.Should().Contain(uri => uri.EndsWith("/session/sess-to-delete"));
}

[Fact]
public async Task DeleteSessionAsync_ShouldThrow_WhenSessionIdEmpty()
{
var adapter = CreateAdapter(new StubHttpMessageHandler((_, _) => throw new InvalidOperationException("Should not be called")));

var act = () => adapter.DeleteSessionAsync(" ");

await act.Should().ThrowAsync<InvalidArgumentException>()
.WithMessage("*sessionId*");
}

private static CommandsAdapter CreateAdapter(HttpMessageHandler httpHandler)
{
var baseUrl = "http://execd.local";
Expand Down
Loading