Exec and streaming
client.Exec (IExecClient) runs commands in a ready sandbox, and client.Watch (ISandboxWatchClient) streams its status, logs, and events. Commands are argument lists: nothing is parsed by a shell unless you run one (["sh", "-c", "..."]).
Run a command
RunAsync buffers the output and returns an ExecResult once the command exits. A non-zero exit code is a result (Succeeded is false), not an exception; failures to reach the sandbox throw OpenShellException.
var result = await client.Exec.RunAsync(
"agent-1",
["python", "-c", "import sys; print(sys.stdin.read().upper())"],
new ExecOptions
{
WorkingDirectory = "/workspace",
Environment = new Dictionary<string, string> { ["LANG"] = "C.UTF-8" },
Stdin = Encoding.UTF8.GetBytes("hello"),
Timeout = TimeSpan.FromSeconds(30),
});
if (!result.Succeeded)
{
Console.Error.WriteLine($"exit {result.ExitCode}: {result.StderrText}");
}
Console.WriteLine(result.StdoutText);
ExecOptions sets the working directory, extra environment variables, standard input, a server-side execution timeout, a TTY, NoLoginShell, and an idempotency key (RequestId).
Python
ExecPythonAsync runs a Python source string with python -c (other SDKs send a pickled callable; .NET code cannot be pickled). ExecPythonStreamAsync streams instead.
// Runs the source with `python -c`; arguments arrive in sys.argv[1:].
var result = await client.Exec.ExecPythonAsync(
"agent-1",
"import sys; print(sum(int(a) for a in sys.argv[1:]))",
new PythonExecOptions { Interpreter = "python3", Arguments = ["1", "2", "3"] });
Stream output
StreamAsync yields ExecOutput events as output arrives, and always ends with an ExecExit event, so a failing command cannot be missed. A stream that ends without an exit event throws OpenShellException (Unavailable).
var exitCode = -1;
await foreach (var e in client.Exec.StreamAsync("agent-1", ["sh", "-c", "echo out; echo err >&2; exit 3"]))
{
switch (e)
{
case ExecOutput { IsStdErr: false } o:
Console.Write(o.Text);
break;
case ExecOutput o:
Console.Error.Write(o.Text);
break;
case ExecExit x:
exitCode = x.ExitCode; // always the last event
break;
}
}
ExecOutput.Data holds the raw bytes; Text decodes them as UTF-8.
Interactive sessions
InteractiveAsync starts a command with a bidirectional stream and returns an ExecInteractiveSession: write standard input, resize the terminal, close the input, and read output, concurrently. Read ReadEventsAsync once; WaitForExitAsync (or the ExitCode task) gives the exit code.
await using var session = await client.Exec.InteractiveAsync(
"agent-1",
["bash", "-l"],
columns: 120,
rows: 40);
// Read the output (once) while writing input.
var output = Task.Run(async () =>
{
await foreach (var e in session.ReadEventsAsync())
{
if (e is ExecOutput o)
{
Console.Write(o.Text);
}
}
});
await session.WriteStdinAsync(Encoding.UTF8.GetBytes("ls /workspace\n"));
await session.ResizeAsync(columns: 160, rows: 50);
await session.WriteStdinAsync(Encoding.UTF8.GetBytes("exit\n"));
await session.CompleteStdinAsync(); // EOF; output keeps flowing
var exitCode = await session.WaitForExitAsync();
await output;
Pass allocateTty: false for a pipe without a terminal. CancelAsync cancels the command and releases the stream, as disposing the session does.
Watch a sandbox
Watch.WatchAsync streams a sandbox's status changes, log lines, platform events (for example Kubernetes events), and draft-policy updates, as subclasses of WatchEvent:
| Event | Carries | Enabled by |
|---|---|---|
| SandboxUpdated | the new Sandbox | FollowStatus (on by default) |
| LogReceived | a LogLine | FollowLogs |
| PlatformEventReceived | a PlatformEvent | FollowEvents |
| DraftPolicyUpdated | a DraftPolicyUpdate | WatchAsync only, whenever the draft changes |
| WatchWarning | a recoverable loss, such as dropped lines; the stream continues | always |
var options = new WatchOptions
{
FollowStatus = true, // SandboxUpdated (the default)
FollowLogs = true, // LogReceived
FollowEvents = true, // PlatformEventReceived
LogTailLines = 50, // start with the last 50 lines
StopOnTerminal = true, // end when the sandbox reaches a terminal phase
};
await foreach (var e in client.Watch.WatchAsync("agent-1", options, cancellationToken))
{
switch (e)
{
case SandboxUpdated u:
Console.WriteLine($"phase {u.Sandbox.Phase}");
break;
case LogReceived l:
Console.WriteLine($"[{l.Line.Source}] {l.Line.Level} {l.Line.Message}");
break;
case PlatformEventReceived p:
Console.WriteLine($"{p.Event.Type} {p.Event.Reason}: {p.Event.Message}");
break;
case DraftPolicyUpdated d:
Console.WriteLine($"{d.Update.TotalPending} policy chunks pending review");
break;
case WatchWarning w:
Console.WriteLine($"warning: {w.Message}");
break;
}
}
The stream reconnects with exponential backoff when the gateway becomes unavailable and resumes from the last cursor it saw; WatchOptions.MaxReconnectAttempts bounds the consecutive attempts (null retries until cancelled). Each event carries an opaque Cursor. Save it to continue after a process restart:
// Store the cursor of the last event you handled; pass it back to continue after a process restart.
var options = new WatchOptions { FollowLogs = true, ResumeAfterCursor = loadCursor() };
await foreach (var e in client.Watch.WatchAsync("agent-1", options))
{
if (e is LogReceived log)
{
Console.WriteLine(log.Line.Message);
}
if (!string.IsNullOrEmpty(e.Cursor))
{
saveCursor(e.Cursor);
}
}
A cursor the gateway has already trimmed is terminal and throws; start a new watch without a cursor to recover.
Logs
GetLogsAsync reads the buffered log lines once; WatchLogsAsync follows logs and platform events without status updates. Filter by source (gateway, sandbox), minimum level, and time.
// A one-off read of the buffered logs.
var logs = await client.Watch.GetLogsAsync("agent-1", new LogOptions
{
Lines = 200,
Sources = ["sandbox"],
MinLevel = "WARN",
});
foreach (var line in logs.Lines)
{
Console.WriteLine($"{line.Time:O} {line.Level} {line.Message}");
}
// Or follow them: WatchLogsAsync never follows status, only the logs and events you ask for.
var follow = new WatchOptions { FollowLogs = true, LogTailLines = 20 };
await foreach (var e in client.Watch.WatchLogsAsync("agent-1", follow))
{
if (e is LogReceived log)
{
Console.WriteLine(log.Line.Message);
}
}
Owner routing
On a multi-replica gateway, exec, interactive exec, port forwarding, and SSH streams go straight to the replica that holds the sandbox's supervisor session. See Multi-replica gateways.