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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ Versioning and the Keep a Changelog structure.

## Unreleased

### Changed

- Set the next Maven version and default user agent to `0.1.2`.

### Added

- Delegated sandbox access token lifecycle methods and a separate token scoped
sandbox handle.

### Fixed

- Detach network members before deleting the network in the example.
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ The SDK targets Java 17 and uses the JDK HTTP client. Public wire models are
immutable records, API failures remain inspectable through `ApiException`, and
the build enforces the [Google Java Style Guide](https://google.github.io/styleguide/javaguide.html).

## 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.

```java
var created = sandbox.createAccessToken();
Sandbox worker = sandbox.withAccessToken(created.token());
var result = worker.runCommand(RunCommandRequest.of("echo", "hello"));
var metadata = sandbox.getAccessToken();
var replacement = sandbox.rotateAccessToken();
sandbox.disableAccessToken();
```

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 pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>sh.createos</groupId>
<artifactId>createos-java-sdk</artifactId>
<version>0.1.1</version>
<version>0.1.2</version>
<name>CreateOS Java SDK</name>
<description>Java SDK for CreateOS Sandbox</description>
<url>https://github.com/NodeOps-app/createos-java-sdk</url>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/sh/createos/CreateOsClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ public static final class Builder {
private URI baseUri;
private HttpClient httpClient =
HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build();
private String userAgent = "createos-java-sdk/0.1.1-SNAPSHOT";
private String userAgent = "createos-java-sdk/0.1.2";
private Duration timeout = Duration.ofSeconds(60);
private RetryPolicy retryPolicy =
new RetryPolicy(2, Duration.ofMillis(500), Duration.ofSeconds(30));
Expand Down
58 changes: 58 additions & 0 deletions src/main/java/sh/createos/Sandbox.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import sh.createos.model.ResizeSandboxResponse;
import sh.createos.model.RunCommandRequest;
import sh.createos.model.RunCommandResponse;
import sh.createos.model.SandboxAccessTokenCreateResponse;
import sh.createos.model.SandboxAccessTokenMetadata;
import sh.createos.model.SandboxData;
import sh.createos.model.SandboxDisk;
import sh.createos.model.SandboxStatus;
Expand Down Expand Up @@ -86,6 +88,62 @@ public ComputerService computer() {
return computer;
}

/** Returns a separate sandbox handle using a delegated token for runtime operations. */
public Sandbox withAccessToken(String token) {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("sandbox access token must not be empty");
}
return new Sandbox(transport.withApiKey(token.trim()), data());
}

/** Creates a delegated token and returns its plaintext value once. */
public SandboxAccessTokenCreateResponse createAccessToken() {
return transport.send(
"POST",
path("/access-token"),
Map.of(),
null,
RequestOptions.DEFAULT,
false,
SandboxAccessTokenCreateResponse.class);
}

/** Returns delegated token state and its redacted hint. */
public SandboxAccessTokenMetadata getAccessToken() {
return transport.send(
"GET",
path("/access-token"),
Map.of(),
null,
RequestOptions.DEFAULT,
false,
SandboxAccessTokenMetadata.class);
}

/** Replaces the current delegated token and returns its new plaintext value. */
public SandboxAccessTokenCreateResponse rotateAccessToken() {
return transport.send(
"POST",
path("/access-token/rotate"),
Map.of(),
null,
RequestOptions.DEFAULT,
false,
SandboxAccessTokenCreateResponse.class);
}

/** Revokes the current delegated token, if present. */
public SandboxAccessTokenMetadata disableAccessToken() {
return transport.send(
"DELETE",
path("/access-token"),
Map.of(),
null,
RequestOptions.DEFAULT,
false,
SandboxAccessTokenMetadata.class);
}

/** Refreshes this handle from the control plane. */
public void refresh() {
update(
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/sh/createos/internal/HttpTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public ObjectMapper objectMapper() {
return objectMapper;
}

/** Copies connection settings while using a separate API credential. */
public HttpTransport withApiKey(String credential) {
return new HttpTransport(baseUri, credential, httpClient, userAgent, timeout, retryPolicy);
}

/** Sends a JSON request and unwraps its JSend response. */
public <T> T send(
String method,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sh.createos.model;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

/** Plaintext delegated token returned only when created or rotated. */
public record SandboxAccessTokenCreateResponse(
String token,
boolean enabled,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("rotated_at") Instant rotatedAt) {}
11 changes: 11 additions & 0 deletions src/main/java/sh/createos/model/SandboxAccessTokenMetadata.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sh.createos.model;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

/** Delegated token state without plaintext credential material. */
public record SandboxAccessTokenMetadata(
boolean enabled,
@JsonProperty("token_hint") String tokenHint,
@JsonProperty("created_at") Instant createdAt,
@JsonProperty("rotated_at") Instant rotatedAt) {}
82 changes: 82 additions & 0 deletions src/test/java/sh/createos/CreateOsClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,88 @@ private CreateOsClient client() {
return CreateOsClient.builder().baseUri(baseUri).apiKey("secret").withoutRetry().build();
}

@Test
void sandboxAccessTokenLifecycleUsesOwnerAndDelegatedCredentials() {
AtomicInteger step = new AtomicInteger();
server.createContext(
"/v1/sandboxes/sb-1",
exchange -> {
int index = step.getAndIncrement();
String key = exchange.getRequestHeaders().getFirst("X-Api-Key");
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
if (index == 0) {
assertEquals("GET", method);
assertEquals("secret", key);
respond(
exchange,
200,
"{\"status\":\"success\",\"data\":{\"id\":\"sb-1\",\"status\":\"running\"}}");
} else if (index == 1) {
assertEquals("POST", method);
assertEquals("/v1/sandboxes/sb-1/access-token", path);
assertEquals("secret", key);
respond(
exchange,
200,
"{\"status\":\"success\",\"data\":{"
+ "\"token\":\"skp_sb_first\",\"enabled\":true,"
+ "\"created_at\":\"2026-09-18T10:00:00Z\"}}");
} else if (index == 2) {
assertEquals("GET", method);
assertEquals("/v1/sandboxes/sb-1/access-token", path);
assertEquals("secret", key);
respond(
exchange,
200,
"{\"status\":\"success\",\"data\":{"
+ "\"enabled\":true,\"token_hint\":\"skp_sb...irst\"}}");
} else if (index == 3) {
assertEquals("POST", method);
assertEquals("/v1/sandboxes/sb-1/exec", path);
assertEquals("skp_sb_first", key);
respond(
exchange,
200,
"{\"status\":\"success\",\"data\":{"
+ "\"result\":{\"stdout\":\"hello\\n\",\"stderr\":\"\","
+ "\"exit_code\":0},\"exec_ms\":1}}");
} else if (index == 4) {
assertEquals("POST", method);
assertEquals("/v1/sandboxes/sb-1/access-token/rotate", path);
assertEquals("secret", key);
respond(
exchange,
200,
"{\"status\":\"success\",\"data\":{"
+ "\"token\":\"skp_sb_second\",\"enabled\":true,"
+ "\"created_at\":\"2026-09-18T10:00:00Z\","
+ "\"rotated_at\":\"2026-09-18T11:00:00Z\"}}");
} else {
assertEquals("DELETE", method);
assertEquals("/v1/sandboxes/sb-1/access-token", path);
assertEquals("secret", key);
respond(exchange, 200, "{\"status\":\"success\",\"data\":{\"enabled\":false}}");
}
});
Sandbox owner = client().getSandbox("sb-1");
var created = owner.createAccessToken();
assertEquals("skp_sb_first", created.token());
assertEquals("skp_sb...irst", owner.getAccessToken().tokenHint());
Sandbox worker = owner.withAccessToken(created.token());
assertEquals(
"hello\n",
worker
.runCommand(sh.createos.model.RunCommandRequest.of("echo", "hello"))
.result()
.standardOutput());
assertEquals(
11, owner.rotateAccessToken().rotatedAt().atOffset(java.time.ZoneOffset.UTC).getHour());
assertFalse(owner.disableAccessToken().enabled());
assertEquals(6, step.get());
assertThrows(IllegalArgumentException.class, () -> owner.withAccessToken(" "));
}

private static void respond(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
Expand Down
Loading