diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2c2995..a093dbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index b60efe1..8e5385f 100644
--- a/README.md
+++ b/README.md
@@ -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)
diff --git a/pom.xml b/pom.xml
index 032532b..bb99d05 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
sh.createos
createos-java-sdk
- 0.1.1
+ 0.1.2
CreateOS Java SDK
Java SDK for CreateOS Sandbox
https://github.com/NodeOps-app/createos-java-sdk
diff --git a/src/main/java/sh/createos/CreateOsClient.java b/src/main/java/sh/createos/CreateOsClient.java
index 5ef417d..469a7f7 100644
--- a/src/main/java/sh/createos/CreateOsClient.java
+++ b/src/main/java/sh/createos/CreateOsClient.java
@@ -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));
diff --git a/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java
index d94ac46..977c9dd 100644
--- a/src/main/java/sh/createos/Sandbox.java
+++ b/src/main/java/sh/createos/Sandbox.java
@@ -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;
@@ -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(
diff --git a/src/main/java/sh/createos/internal/HttpTransport.java b/src/main/java/sh/createos/internal/HttpTransport.java
index 4ddf1b1..2a579aa 100644
--- a/src/main/java/sh/createos/internal/HttpTransport.java
+++ b/src/main/java/sh/createos/internal/HttpTransport.java
@@ -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 send(
String method,
diff --git a/src/main/java/sh/createos/model/SandboxAccessTokenCreateResponse.java b/src/main/java/sh/createos/model/SandboxAccessTokenCreateResponse.java
new file mode 100644
index 0000000..5733172
--- /dev/null
+++ b/src/main/java/sh/createos/model/SandboxAccessTokenCreateResponse.java
@@ -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) {}
diff --git a/src/main/java/sh/createos/model/SandboxAccessTokenMetadata.java b/src/main/java/sh/createos/model/SandboxAccessTokenMetadata.java
new file mode 100644
index 0000000..c258eb1
--- /dev/null
+++ b/src/main/java/sh/createos/model/SandboxAccessTokenMetadata.java
@@ -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) {}
diff --git a/src/test/java/sh/createos/CreateOsClientTest.java b/src/test/java/sh/createos/CreateOsClientTest.java
index 7ca7c17..896bffe 100644
--- a/src/test/java/sh/createos/CreateOsClientTest.java
+++ b/src/test/java/sh/createos/CreateOsClientTest.java
@@ -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");