From 5f61c750ff0c84f4b741ecf954f42122a525f9cf Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 01:41:16 +0530 Subject: [PATCH 1/4] feat(sandbox): add delegated access tokens --- CHANGELOG.md | 5 ++ README.md | 21 ++++++++ src/main/java/sh/createos/Sandbox.java | 34 +++++++++++++ .../sh/createos/internal/HttpTransport.java | 5 ++ .../SandboxAccessTokenCreateResponse.java | 11 ++++ .../model/SandboxAccessTokenMetadata.java | 11 ++++ .../java/sh/createos/CreateOsClientTest.java | 51 +++++++++++++++++++ 7 files changed, 138 insertions(+) create mode 100644 src/main/java/sh/createos/model/SandboxAccessTokenCreateResponse.java create mode 100644 src/main/java/sh/createos/model/SandboxAccessTokenMetadata.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c2c2995..42aeb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ Versioning and the Keep a Changelog structure. ## Unreleased +### 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/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java index d94ac46..0698fad 100644 --- a/src/main/java/sh/createos/Sandbox.java +++ b/src/main/java/sh/createos/Sandbox.java @@ -20,6 +20,8 @@ import sh.createos.model.RunCommandRequest; import sh.createos.model.RunCommandResponse; import sh.createos.model.SandboxData; +import sh.createos.model.SandboxAccessTokenCreateResponse; +import sh.createos.model.SandboxAccessTokenMetadata; import sh.createos.model.SandboxDisk; import sh.createos.model.SandboxStatus; @@ -86,6 +88,38 @@ 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..73e7c81 100644 --- a/src/test/java/sh/createos/CreateOsClientTest.java +++ b/src/test/java/sh/createos/CreateOsClientTest.java @@ -174,6 +174,57 @@ 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"); From dfdeef1b78aa41077c0720caf9bb1731722b4f43 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 01:45:26 +0530 Subject: [PATCH 2/4] chore: bump SDK version to 0.2.0 --- pom.xml | 2 +- src/main/java/sh/createos/CreateOsClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 032532b..537d720 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ sh.createos createos-java-sdk - 0.1.1 + 0.2.0 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..39a238d 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.2.0"; private Duration timeout = Duration.ofSeconds(60); private RetryPolicy retryPolicy = new RetryPolicy(2, Duration.ofMillis(500), Duration.ofSeconds(30)); From ef1a3f5c7a90a28eb766a85ba58d9a51362591b8 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 01:59:20 +0530 Subject: [PATCH 3/4] fix: set SDK patch version to 0.1.2 --- pom.xml | 2 +- src/main/java/sh/createos/CreateOsClient.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 537d720..bb99d05 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ sh.createos createos-java-sdk - 0.2.0 + 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 39a238d..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.2.0"; + 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)); From d7eb4c75f5ebe28eb8b136c4bfb7266bc8390612 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 02:10:43 +0530 Subject: [PATCH 4/4] fix: satisfy Java CI formatting and update changelog --- CHANGELOG.md | 4 + src/main/java/sh/createos/Sandbox.java | 42 +++++-- .../java/sh/createos/CreateOsClientTest.java | 107 +++++++++++------- 3 files changed, 106 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42aeb02..a093dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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 diff --git a/src/main/java/sh/createos/Sandbox.java b/src/main/java/sh/createos/Sandbox.java index 0698fad..977c9dd 100644 --- a/src/main/java/sh/createos/Sandbox.java +++ b/src/main/java/sh/createos/Sandbox.java @@ -19,9 +19,9 @@ import sh.createos.model.ResizeSandboxResponse; import sh.createos.model.RunCommandRequest; import sh.createos.model.RunCommandResponse; -import sh.createos.model.SandboxData; 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; @@ -98,26 +98,50 @@ public Sandbox withAccessToken(String token) { /** 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); + 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); + 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); + 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); + return transport.send( + "DELETE", + path("/access-token"), + Map.of(), + null, + RequestOptions.DEFAULT, + false, + SandboxAccessTokenMetadata.class); } /** Refreshes this handle from the control plane. */ diff --git a/src/test/java/sh/createos/CreateOsClientTest.java b/src/test/java/sh/createos/CreateOsClientTest.java index 73e7c81..896bffe 100644 --- a/src/test/java/sh/createos/CreateOsClientTest.java +++ b/src/test/java/sh/createos/CreateOsClientTest.java @@ -177,49 +177,80 @@ private CreateOsClient client() { @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}}"); - } - }); + 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()); + 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(" "));