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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

## Unreleased

- Added checkpoint create, list, restore, and delete operations scoped
to a running sandbox through `SandboxInstance.Checkpoints`.
- Added reusable disk and full snapshot create, get, list, and delete operations,
plus sandbox restoration through `CreateSandboxRequest.SnapshotId`.
- Added sandbox labels to create and response models, AND-matched label
filtering in `ListSandboxesAsync()`, and label merging through
`SandboxInstance.SetLabelsAsync()`.
- Set the next NuGet version and default user agent to `0.1.4`.

## [0.1.3](https://github.com/NodeOps-app/createos-csharp-sdk/releases/tag/v0.1.3) — 2026-09-21

Expand Down
6 changes: 6 additions & 0 deletions CreateOS.Sandbox.sln
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Network", "examples\Network
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Snapshots", "examples\Snapshots\Snapshots.csproj", "{A1000010-0000-4000-8000-000000000010}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Checkpoints", "examples\Checkpoints\Checkpoints.csproj", "{A1000011-0000-4000-8000-000000000011}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreateOS.Sandbox.Tests", "tests\CreateOS.Sandbox.Tests\CreateOS.Sandbox.Tests.csproj", "{B2000001-0000-4000-8000-000000000001}"
EndProject
Global
Expand Down Expand Up @@ -82,6 +84,10 @@ Global
{A1000010-0000-4000-8000-000000000010}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000010-0000-4000-8000-000000000010}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000010-0000-4000-8000-000000000010}.Release|Any CPU.Build.0 = Release|Any CPU
{A1000011-0000-4000-8000-000000000011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000011-0000-4000-8000-000000000011}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000011-0000-4000-8000-000000000011}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1000011-0000-4000-8000-000000000011}.Release|Any CPU.Build.0 = Release|Any CPU
{B2000001-0000-4000-8000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2000001-0000-4000-8000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2000001-0000-4000-8000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ capture requires a running source sandbox with no attached external disks.
Each account may retain up to five snapshots across both types. See the runnable
[snapshot example](examples/Snapshots/README.md).

## Checkpoint a directory

Checkpoints keep local file history inside one running sandbox. Stop
writers before create or restore; processes are not paused automatically.

```csharp
var checkpoint = await sandbox.Checkpoints.CreateAsync(
new CreateCheckpointRequest
{
Path = "/workspace/project",
Name = "before-update",
});

var checkpoints = await sandbox.Checkpoints.ListAsync();
var restored = await sandbox.Checkpoints.RestoreAsync(checkpoint.Id);
await sandbox.Checkpoints.DeleteAsync(checkpoint.Id);
```

Restore always uses the checkpoint's original path. Checkpoints remain on the
sandbox disk and disappear with it. See the runnable
[checkpoint example](examples/Checkpoints/README.md).

## Delegate access to one sandbox

An owner can create one delegated token for a sandbox. Plaintext is returned
Expand Down
1 change: 1 addition & 0 deletions examples/Checkpoints/Checkpoints.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
44 changes: 44 additions & 0 deletions examples/Checkpoints/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using CreateOS.Sandbox;

const string checkpointPath = "/workspace/checkpoint-demo";

using var client = new SandboxClient();
var sandbox = await client.CreateSandboxAsync(new CreateSandboxRequest
{
Shape = "s-1vcpu-1gb",
RootFileSystem = "devbox:1"
});
Checkpoint? checkpoint = null;

try
{
await RunAsync(sandbox, $"mkdir -p {checkpointPath} && printf 'version one' > {checkpointPath}/value");
checkpoint = await sandbox.Checkpoints.CreateAsync(new CreateCheckpointRequest
{
Path = checkpointPath,
Name = "before-update"
});
await RunAsync(sandbox, $"printf 'version two' > {checkpointPath}/value");
await sandbox.Checkpoints.RestoreAsync(checkpoint.Id);
var result = await RunAsync(sandbox, $"cat {checkpointPath}/value");
Console.WriteLine($"Restored {checkpoint.Id}: {result.Result.StandardOutput}");
}
finally
{
try
{
if (checkpoint is not null)
await sandbox.Checkpoints.DeleteAsync(checkpoint.Id, CancellationToken.None);
}
finally
{
await sandbox.DestroyAsync(CancellationToken.None);
}
}

static Task<RunCommandResponse> RunAsync(SandboxInstance sandbox, string script) =>
sandbox.RunCommandAsync(new RunCommandRequest
{
Command = "sh",
Arguments = ["-c", script]
});
12 changes: 12 additions & 0 deletions examples/Checkpoints/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Checkpoints

Capture a directory, modify it, restore its earlier contents, and remove the
checkpoint.

```sh
export CREATEOS_API_KEY="your-api-key"
dotnet run --project examples/Checkpoints/Checkpoints.csproj
```

The example creates live resources and cleans them up when it finishes. Stop
all writers before creating or restoring a checkpoint.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ the local project reference. No CreateOS API key belongs in the repository.
| [CommandStreaming](CommandStreaming/README.md) | Read command output as it arrives |
| [FilesAndSnapshots](FilesAndSnapshots/README.md) | Upload/download files and snapshot a sandbox |
| [Snapshots](Snapshots/README.md) | Capture and restore a reusable sandbox snapshot |
| [Checkpoints](Checkpoints/README.md) | Capture and restore local directory history |
| [IngressPreview](IngressPreview/README.md) | Expose a service through an ingress preview URL |
| [Network](Network/README.md) | Connect sandboxes on a private network |
| [CustomTemplate](CustomTemplate/README.md) | Build and use a custom template |
Expand Down
39 changes: 39 additions & 0 deletions src/CreateOS.Sandbox/CheckpointsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System.Text.Json;
using CreateOS.Sandbox.Internal;

namespace CreateOS.Sandbox;

/// <summary>Manages local checkpoints inside one running sandbox.</summary>
public sealed class CheckpointsService
{
private readonly SandboxInstance _instance;
private readonly Transport _transport;

internal CheckpointsService(SandboxInstance instance, Transport transport)
{
_instance = instance;
_transport = transport;
}

/// <summary>Captures an eligible absolute directory.</summary>
public async Task<Checkpoint> CreateAsync(CreateCheckpointRequest request, CancellationToken cancellationToken = default) =>
await _transport.SendAsync<Checkpoint>(HttpMethod.Post, Path(), request, cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new JsonException("Checkpoint create response contained no data.");

/// <summary>Lists checkpoints oldest first.</summary>
public async Task<IReadOnlyList<Checkpoint>> ListAsync(CancellationToken cancellationToken = default) =>
await _transport.SendAsync<Checkpoint[]>(HttpMethod.Get, Path(), cancellationToken: cancellationToken).ConfigureAwait(false)
?? [];

/// <summary>Restores a checkpoint at its original directory path.</summary>
public async Task<CheckpointRestoreResponse> RestoreAsync(string checkpointId, CancellationToken cancellationToken = default) =>
await _transport.SendAsync<CheckpointRestoreResponse>(HttpMethod.Post, Path(checkpointId, "/restore"), cancellationToken: cancellationToken).ConfigureAwait(false)
?? throw new JsonException("Checkpoint restore response contained no data.");

/// <summary>Deletes a checkpoint record while retaining referenced layers.</summary>
public async Task DeleteAsync(string checkpointId, CancellationToken cancellationToken = default) =>
_ = await _transport.SendAsync<object>(HttpMethod.Delete, Path(checkpointId), cancellationToken: cancellationToken).ConfigureAwait(false);

private string Path(string? checkpointId = null, string suffix = "") =>
_instance.Path("/checkpoints") + (checkpointId is null ? "" : "/" + SandboxClient.Segment(checkpointId)) + suffix;
}
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.3</Version>
<Version>0.1.4</Version>
<Authors>CreateOS</Authors>
<Description>Official .NET SDK for CreateOS Sandbox.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
Expand Down
18 changes: 18 additions & 0 deletions src/CreateOS.Sandbox/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public sealed record CreateSnapshotRequest
[JsonPropertyName("type")] public required SnapshotType Type { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
}
public sealed record CreateCheckpointRequest
{
[JsonPropertyName("path")] public required string Path { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
}
public sealed record ForkSandboxRequest
{
[JsonPropertyName("start_paused")] public bool? StartPaused { get; init; }
Expand Down Expand Up @@ -138,6 +143,19 @@ public sealed record SandboxData
[JsonPropertyName("auto_pause_after_seconds")] public int? AutoPauseAfterSeconds { get; init; }
[JsonPropertyName("snapshot_id")] public string? SnapshotId { get; init; }
}
public sealed record Checkpoint
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("path")] public required string Path { get; init; }
[JsonPropertyName("name")] public string? Name { get; init; }
[JsonPropertyName("parent_id")] public string? ParentId { get; init; }
[JsonPropertyName("created_at")] public DateTimeOffset CreatedAt { get; init; }
}
public sealed record CheckpointRestoreResponse
{
[JsonPropertyName("checkpoint_id")] public required string CheckpointId { get; init; }
[JsonPropertyName("path")] public required string Path { get; init; }
}
public sealed record Snapshot
{
public required string Id { get; init; }
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.3";
public string UserAgent { get; set; } = "createos-csharp-sdk/0.1.4";
/// <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
3 changes: 2 additions & 1 deletion src/CreateOS.Sandbox/SandboxInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public sealed class SandboxInstance
public FilesService Files { get; }
public ProcessesService Processes { get; }
public ComputerService Computer { get; }
public CheckpointsService Checkpoints { get; }
public string Id { get { lock (_gate) return _data.Id; } }
public string? Name { get { lock (_gate) return _data.Name; } }
public SandboxStatus Status { get { lock (_gate) return _data.Status; } }
Expand All @@ -26,7 +27,7 @@ public sealed class SandboxInstance
internal SandboxInstance(Transport transport, SandboxData data)
{
_transport = transport; _data = data;
Files = new FilesService(this, transport); Processes = new ProcessesService(this, transport); Computer = new ComputerService(this, transport);
Files = new FilesService(this, transport); Processes = new ProcessesService(this, transport); Computer = new ComputerService(this, transport); Checkpoints = new CheckpointsService(this, transport);
}
internal string Path(string suffix = "") => $"/v1/sandboxes/{SandboxClient.Segment(Id)}{suffix}";
private void Update(SandboxData data) { lock (_gate) _data = data; }
Expand Down
30 changes: 30 additions & 0 deletions tests/CreateOS.Sandbox.Tests/SandboxInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,36 @@ public async Task SetLabelsPatchesAndUpdatesCachedState()
Assert.Equal("platform", instance.Labels["team"]);
}

[Fact]
public async Task CheckpointLifecycleEncodesIds()
{
var responses = new Queue<string>([
"{\"status\":\"success\",\"data\":{\"id\":\"cp_first\",\"path\":\"/workspace/app\",\"created_at\":\"2026-11-05T12:00:00Z\"}}",
"{\"status\":\"success\",\"data\":[{\"id\":\"cp_first\",\"path\":\"/workspace/app\",\"created_at\":\"2026-11-05T12:00:00Z\"}]}",
"{\"status\":\"success\",\"data\":{\"checkpoint_id\":\"cp_first/segment\",\"path\":\"/workspace/app\"}}",
"{\"status\":\"success\",\"data\":{}}",
]);
var requests = new List<(string Method, string Path, string Body)>();
using var transport = CreateTransport(new DelegateHandler(request =>
{
requests.Add((request.Method.Method, request.RequestUri!.AbsolutePath, request.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? ""));
return Json(request, responses.Dequeue());
}));
var instance = Instance(transport, ingress: false);

var checkpoint = await instance.Checkpoints.CreateAsync(new CreateCheckpointRequest { Path = "/workspace/app", Name = "before-update" });
var checkpoints = await instance.Checkpoints.ListAsync();
var restored = await instance.Checkpoints.RestoreAsync("cp_first/segment");
await instance.Checkpoints.DeleteAsync("cp_first/segment");

Assert.Equal("cp_first", checkpoint.Id);
Assert.Single(checkpoints);
Assert.Equal("/workspace/app", restored.Path);
Assert.Contains("\"path\":\"/workspace/app\"", requests[0].Body, StringComparison.Ordinal);
Assert.Equal("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment/restore", requests[2].Path);
Assert.Equal("/v1/sandboxes/sb-1/checkpoints/cp_first%2Fsegment", requests[3].Path);
}

[Fact]
public async Task ShellThrowsForNonzeroExit()
{
Expand Down
Loading