diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d16dec..3c14673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 37c7760..339177b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj b/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj index 7cc3678..fee73cd 100644 --- a/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj +++ b/src/CreateOS.Sandbox/CreateOS.Sandbox.csproj @@ -6,7 +6,7 @@ true $(NoWarn);1591 CreateOS.Sandbox - 0.1.2 + 0.1.3 CreateOS Official .NET SDK for CreateOS Sandbox. README.md diff --git a/src/CreateOS.Sandbox/Internal/Transport.cs b/src/CreateOS.Sandbox/Internal/Transport.cs index 7a0c1e7..4339d74 100644 --- a/src/CreateOS.Sandbox/Internal/Transport.cs +++ b/src/CreateOS.Sandbox/Internal/Transport.cs @@ -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 SendAsync(HttpMethod method, string path, object? body = null, RequestOptions? options = null, diff --git a/src/CreateOS.Sandbox/Models.cs b/src/CreateOS.Sandbox/Models.cs index a5a1698..a6d18da 100644 --- a/src/CreateOS.Sandbox/Models.cs +++ b/src/CreateOS.Sandbox/Models.cs @@ -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; } } +/// A plaintext delegated credential returned only on creation or rotation. +public sealed record SandboxAccessTokenCreateResponse( + string Token, bool Enabled, + [property: JsonPropertyName("created_at")] DateTimeOffset CreatedAt, + [property: JsonPropertyName("rotated_at")] DateTimeOffset? RotatedAt = null); + +/// Delegated token state without plaintext credential material. +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); diff --git a/src/CreateOS.Sandbox/Options.cs b/src/CreateOS.Sandbox/Options.cs index 4aef8e5..b2498e2 100644 --- a/src/CreateOS.Sandbox/Options.cs +++ b/src/CreateOS.Sandbox/Options.cs @@ -16,7 +16,7 @@ public sealed class SandboxClientOptions /// Gets or sets the maximum delay between retry attempts. public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30); /// Gets or sets the value sent in the User-Agent header. - public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.2"; + public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.3"; /// Gets or sets a custom handler for proxy, TLS, or connection settings. Redirects are always disabled. public HttpClientHandler? HttpClientHandler { get; set; } } diff --git a/src/CreateOS.Sandbox/SandboxInstance.cs b/src/CreateOS.Sandbox/SandboxInstance.cs index dcd4f44..9ab228f 100644 --- a/src/CreateOS.Sandbox/SandboxInstance.cs +++ b/src/CreateOS.Sandbox/SandboxInstance.cs @@ -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; } + /// Returns a separate handle using a delegated sandbox token. + 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); + } + + /// Creates a delegated token and returns its plaintext value once. + public Task CreateAccessTokenAsync(CancellationToken token = default) => + RequiredAsync(HttpMethod.Post, Path("/access-token"), null, token); + + /// Returns token state and its redacted hint. + public Task GetAccessTokenAsync(CancellationToken token = default) => + RequiredAsync(HttpMethod.Get, Path("/access-token"), null, token); + + /// Replaces an existing token and returns its new plaintext value. + public Task RotateAccessTokenAsync(CancellationToken token = default) => + RequiredAsync(HttpMethod.Post, Path("/access-token/rotate"), null, token); + + /// Revokes the current token, if present. + public Task DisableAccessTokenAsync(CancellationToken token = default) => + RequiredAsync(HttpMethod.Delete, Path("/access-token"), null, token); private async Task LifecycleAsync(HttpMethod method, string suffix, object? body, CancellationToken token) { var data = await _transport.SendAsync(method, Path(suffix), body, cancellationToken: token).ConfigureAwait(false) ?? throw new JsonException("Lifecycle response contained no data."); diff --git a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs index d103818..0120307 100644 --- a/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs +++ b/tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs @@ -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([ + "{\"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(() => owner.WithAccessToken(" ")); + } + private static SandboxInstance Instance(Transport transport, bool ingress) => new(transport, new SandboxData { Id = "sb-1",