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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

## Unreleased

- Set the next NuGet version and default user agent to `0.1.3`.
- Added delegated sandbox access token lifecycle methods and a separate token scoped sandbox handle.

- Enforce the execution example's 1 MiB request-body limit for chunked requests.
- Allow longer sandbox creation from custom templates in the example.
- Prepare `0.1.2` for public nuget.org distribution with token-free installation and OIDC-based release publishing.
- Prepare public nuget.org distribution with token-free installation and OIDC-based release publishing.

## [0.1.1](https://github.com/NodeOps-app/createos-csharp-sdk/releases/tag/v0.1.1) — 2026-09-16

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,27 @@ var client = new SandboxClient(new SandboxClientOptions
`CREATEOS_SANDBOX_BASE_URL` optionally configures the endpoint when `BaseUri`
is not supplied.

## Delegate access to one sandbox

An owner can create one delegated token for a sandbox. Plaintext is returned
only on creation or rotation; inspection provides a redacted hint.

```csharp
var created = await sandbox.CreateAccessTokenAsync();
var worker = sandbox.WithAccessToken(created.Token);
var result = await worker.RunCommandAsync(new RunCommandRequest { Command = "echo", Arguments = ["hello"] });
var metadata = await sandbox.GetAccessTokenAsync();
var replacement = await sandbox.RotateAccessTokenAsync();
await sandbox.DisableAccessTokenAsync();
```

Use the owner's handle for token management. The delegated handle can operate
its bound sandbox, including commands, files, processes, computer use, pause,
resume, and destroy; it cannot manage tokens or account resources. Creating
another enabled token returns HTTP 409; rotation requires an existing token.
Disabling is idempotent. Revocation is immediate in the home region and
propagates asynchronously to peer regions.

## Documentation

- [CreateOS Sandbox overview](https://nodeops.network/createos/docs/Sandbox/Overview)
Expand Down
2 changes: 1 addition & 1 deletion src/CreateOS.Sandbox/CreateOS.Sandbox.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<PackageId>CreateOS.Sandbox</PackageId>
<Version>0.1.2</Version>
<Version>0.1.3</Version>
<Authors>CreateOS</Authors>
<Description>Official .NET SDK for CreateOS Sandbox.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand Down
10 changes: 10 additions & 0 deletions src/CreateOS.Sandbox/Internal/Transport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ internal Transport(SandboxClientOptions options, HttpMessageHandler? handlerOver
_http.Timeout = Timeout.InfiniteTimeSpan;
}

private Transport(Transport source, string apiKey)
{
_baseUri = source._baseUri;
_apiKey = apiKey;
_http = source._http;
_options = source._options;
}

internal Transport WithApiKey(string apiKey) => new(this, apiKey);

internal Uri BaseUri => _baseUri;

internal async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body = null, RequestOptions? options = null,
Expand Down
12 changes: 12 additions & 0 deletions src/CreateOS.Sandbox/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ public sealed record SandboxData
[JsonPropertyName("forked_from")] public string? ForkedFrom { get; init; }
[JsonPropertyName("auto_pause_after_seconds")] public int? AutoPauseAfterSeconds { get; init; }
}
/// <summary>A plaintext delegated credential returned only on creation or rotation.</summary>
public sealed record SandboxAccessTokenCreateResponse(
string Token, bool Enabled,
[property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt,
[property: JsonPropertyName("rotated_at")] DateTimeOffset? RotatedAt = null);

/// <summary>Delegated token state without plaintext credential material.</summary>
public sealed record SandboxAccessTokenMetadata(
bool Enabled,
[property: JsonPropertyName("token_hint")] string? TokenHint = null,
[property: JsonPropertyName("created_at")] DateTimeOffset? CreatedAt = null,
[property: JsonPropertyName("rotated_at")] DateTimeOffset? RotatedAt = null);
public sealed record CommandResult([property: JsonPropertyName("stdout")] string StandardOutput, [property: JsonPropertyName("stderr")] string StandardError, [property: JsonPropertyName("exit_code")] int ExitCode, [property: JsonPropertyName("error")] string? ErrorMessage = null);
public sealed record RunCommandResponse(CommandResult Result, [property: JsonPropertyName("exec_ms")] double ExecutionMilliseconds);
public sealed record CommandStreamEvent(ExecStreamEventType Type, string? Data = null, int? ExitCode = null, string? ErrorMessage = null);
Expand Down
2 changes: 1 addition & 1 deletion src/CreateOS.Sandbox/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public sealed class SandboxClientOptions
/// <summary>Gets or sets the maximum delay between retry attempts.</summary>
public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Gets or sets the value sent in the <c>User-Agent</c> header.</summary>
public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.2";
public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.3";
/// <summary>Gets or sets a custom handler for proxy, TLS, or connection settings. Redirects are always disabled.</summary>
public HttpClientHandler? HttpClientHandler { get; set; }
}
Expand Down
22 changes: 22 additions & 0 deletions src/CreateOS.Sandbox/SandboxInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ internal SandboxInstance(Transport transport, SandboxData data)
}
internal string Path(string suffix = "") => $"/v1/sandboxes/{SandboxClient.Segment(Id)}{suffix}";
private void Update(SandboxData data) { lock (_gate) _data = data; }
/// <summary>Returns a separate handle using a delegated sandbox token.</summary>
public SandboxInstance WithAccessToken(string token)
{
if (string.IsNullOrWhiteSpace(token)) throw new ArgumentException("Sandbox access token must not be empty.", nameof(token));
return new SandboxInstance(_transport.WithApiKey(token.Trim()), Data);
}

/// <summary>Creates a delegated token and returns its plaintext value once.</summary>
public Task<SandboxAccessTokenCreateResponse> CreateAccessTokenAsync(CancellationToken token = default) =>
RequiredAsync<SandboxAccessTokenCreateResponse>(HttpMethod.Post, Path("/access-token"), null, token);

/// <summary>Returns token state and its redacted hint.</summary>
public Task<SandboxAccessTokenMetadata> GetAccessTokenAsync(CancellationToken token = default) =>
RequiredAsync<SandboxAccessTokenMetadata>(HttpMethod.Get, Path("/access-token"), null, token);

/// <summary>Replaces an existing token and returns its new plaintext value.</summary>
public Task<SandboxAccessTokenCreateResponse> RotateAccessTokenAsync(CancellationToken token = default) =>
RequiredAsync<SandboxAccessTokenCreateResponse>(HttpMethod.Post, Path("/access-token/rotate"), null, token);

/// <summary>Revokes the current token, if present.</summary>
public Task<SandboxAccessTokenMetadata> DisableAccessTokenAsync(CancellationToken token = default) =>
RequiredAsync<SandboxAccessTokenMetadata>(HttpMethod.Delete, Path("/access-token"), null, token);
private async Task<SandboxData> LifecycleAsync(HttpMethod method, string suffix, object? body, CancellationToken token)
{
var data = await _transport.SendAsync<SandboxData>(method, Path(suffix), body, cancellationToken: token).ConfigureAwait(false) ?? throw new JsonException("Lifecycle response contained no data.");
Expand Down
35 changes: 35 additions & 0 deletions tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,41 @@ public async Task ShellThrowsForNonzeroExit()
Assert.Contains("status 2", exception.Message, StringComparison.Ordinal);
}

[Fact]
public async Task AccessTokenLifecycleUsesScopedCredential()
{
var seen = new List<(string Method, string Path, string? Key)>();
var responses = new Queue<string>([
"{\"status\":\"success\",\"data\":{\"token\":\"skp_sb_first\",\"enabled\":true,\"created_at\":\"2026-09-18T10:00:00Z\"}}",
"{\"status\":\"success\",\"data\":{\"enabled\":true,\"token_hint\":\"skp_sb...irst\"}}",
"{\"status\":\"success\",\"data\":{\"result\":{\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exit_code\":0},\"exec_ms\":1}}",
"{\"status\":\"success\",\"data\":{\"token\":\"skp_sb_second\",\"enabled\":true,\"created_at\":\"2026-09-18T10:00:00Z\",\"rotated_at\":\"2026-09-18T11:00:00Z\"}}",
"{\"status\":\"success\",\"data\":{\"enabled\":false}}",
]);
using var transport = CreateTransport(new DelegateHandler(request =>
{
seen.Add((request.Method.Method, request.RequestUri!.AbsolutePath, request.Headers.GetValues("X-Api-Key").Single()));
return Json(request, responses.Dequeue());
}));
var owner = Instance(transport, ingress: false);
var created = await owner.CreateAccessTokenAsync();
Assert.Equal("skp_sb_first", created.Token);
Assert.Equal("skp_sb...irst", (await owner.GetAccessTokenAsync()).TokenHint);
var worker = owner.WithAccessToken(created.Token);
Assert.NotSame(owner.Files, worker.Files);
Assert.Equal("hello\n", (await worker.RunCommandAsync(new RunCommandRequest { Command = "echo", Arguments = ["hello"] })).Result.StandardOutput);
Assert.Equal(11, (await owner.RotateAccessTokenAsync()).RotatedAt?.Hour);
Assert.False((await owner.DisableAccessTokenAsync()).Enabled);
Assert.Equal(new (string Method, string Path, string? Key)[] {
("POST", "/v1/sandboxes/sb-1/access-token", "sdk-key"),
("GET", "/v1/sandboxes/sb-1/access-token", "sdk-key"),
("POST", "/v1/sandboxes/sb-1/exec", "skp_sb_first"),
("POST", "/v1/sandboxes/sb-1/access-token/rotate", "sdk-key"),
("DELETE", "/v1/sandboxes/sb-1/access-token", "sdk-key"),
}, seen.ToArray());
Assert.Throws<ArgumentException>(() => owner.WithAccessToken(" "));
}

private static SandboxInstance Instance(Transport transport, bool ingress) => new(transport, new SandboxData
{
Id = "sb-1",
Expand Down
Loading