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");