From 498475726798c5822359d5684dd28573922dfaec Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 01:51:31 +0530 Subject: [PATCH 1/3] feat(sandbox): add delegated access tokens --- Cargo.toml | 2 +- README.md | 32 +++++++++ src/instance.rs | 180 ++++++++++++++++++++++++++++++++++++++++++++++- src/models.rs | 26 +++++++ src/transport.rs | 8 +++ 5 files changed, 245 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4ccdb73..5fecc55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "createos" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.85" description = "Async Rust SDK for CreateOS cloud sandboxes" diff --git a/README.md b/README.md index c4d62d0..9cd4365 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,38 @@ Use HTTPS for every non-loopback endpoint. For custom proxy or TLS settings, pass a `reqwest::ClientBuilder` to `Client::builder().http_client(...)`; the SDK still disables redirects so credentials cannot be forwarded to another origin. +## Delegate access to one sandbox + +The owner can create one delegated token for a sandbox. Creation and rotation +return its plaintext value once; inspection returns only a redacted hint. + +```rust,no_run +# use createos::{Client, RunCommandRequest}; +# async fn example(client: Client) -> createos::Result<()> { +let sandbox = client.sandbox("sb-1").await?; +let created = sandbox.create_access_token().await?; +let worker = sandbox.with_access_token(&created.token)?; +let result = worker.run_command( + RunCommandRequest { command: "echo".into(), arguments: vec!["hello".into()], ..Default::default() }, + Default::default(), +).await?; +println!("{}", result.result.standard_output); + +let metadata = sandbox.get_access_token().await?; +println!("{:?}", metadata.token_hint); +let replacement = sandbox.rotate_access_token().await?; +// Give replacement.token to the worker instead of the old token. +sandbox.disable_access_token().await?; +# Ok(()) } +``` + +Keep the owner 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/instance.rs b/src/instance.rs index 46f81d7..a3248b7 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -2,8 +2,9 @@ use crate::{ AttachDiskOptions, BandwidthView, ComputerService, DetachDiskOptions, DiskAttachment, DiskDetachedResponse, EgressView, Error, ExecOptions, FilesService, ForkSandboxRequest, PaginationOptions, ProcessesService, RequestOptions, ResizeSandboxResponse, Result, - RunCommandRequest, RunCommandResponse, Sandbox, SandboxDisk, SandboxStatus, WaitOptions, - client::encode, client::fetch_all, transport::Transport, + RunCommandRequest, RunCommandResponse, Sandbox, SandboxAccessTokenCreateResponse, + SandboxAccessTokenMetadata, SandboxDisk, SandboxStatus, WaitOptions, client::encode, + client::fetch_all, transport::Transport, }; use reqwest::Method; use serde::Serialize; @@ -62,6 +63,71 @@ impl Instance { pub fn computer(&self) -> ComputerService { ComputerService::new(self.clone()) } + + /// Returns a separate handle that authenticates with a delegated sandbox token. + /// + /// Keep the original owner handle for token management. The server rejects + /// management operations made with a delegated credential. + pub fn with_access_token(&self, token: &str) -> Result { + let token = token.trim(); + if token.is_empty() { + return Err(Error::InvalidArgument( + "sandbox access token must not be empty".into(), + )); + } + Ok(Self::new( + self.transport.with_api_key(token.to_owned()), + self.data(), + )) + } + + /// Creates a delegated token and returns its plaintext value once. + pub async fn create_access_token(&self) -> Result { + self.transport + .empty( + Method::POST, + &self.path("/access-token"), + &[], + &RequestOptions::default(), + ) + .await + } + + /// Returns delegated token state and a redacted hint. + pub async fn get_access_token(&self) -> Result { + self.transport + .get( + &self.path("/access-token"), + &[], + &RequestOptions::default(), + true, + ) + .await + } + + /// Replaces an existing delegated token and returns its new plaintext value. + pub async fn rotate_access_token(&self) -> Result { + self.transport + .empty( + Method::POST, + &self.path("/access-token/rotate"), + &[], + &RequestOptions::default(), + ) + .await + } + + /// Revokes the delegated token, if present. + pub async fn disable_access_token(&self) -> Result { + self.transport + .empty( + Method::DELETE, + &self.path("/access-token"), + &[], + &RequestOptions::default(), + ) + .await + } pub(crate) fn path(&self, suffix: &str) -> String { format!("/v1/sandboxes/{}{suffix}", encode(&self.id())) } @@ -611,3 +677,113 @@ async fn self_signal(action: &str, reason: Option<&str>) -> Result<()> { ))) } } + +#[cfg(test)] +mod access_token_tests { + use super::*; + use std::{ + io::{Read as _, Write as _}, + net::TcpListener, + sync::mpsc, + }; + + #[tokio::test] + async fn token_lifecycle_keeps_owner_and_worker_credentials_separate() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let responses = [ + r#"{"status":"success","data":{"token":"skp_sb_first","enabled":true,"created_at":"2026-09-18T10:00:00Z"}}"#, + r#"{"status":"success","data":{"enabled":true,"token_hint":"skp_sb...irst","created_at":"2026-09-18T10:00:00Z"}}"#, + r#"{"status":"success","data":{"result":{"stdout":"hello\n","stderr":"","exit_code":0},"exec_ms":1}}"#, + r#"{"status":"success","data":{"token":"skp_sb_second","enabled":true,"created_at":"2026-09-18T10:00:00Z","rotated_at":"2026-09-18T11:00:00Z"}}"#, + r#"{"status":"success","data":{"enabled":false}}"#, + ]; + std::thread::spawn(move || { + for body in responses { + let (mut connection, _) = listener.accept().unwrap(); + let mut request = [0_u8; 8192]; + let length = connection.read(&mut request).unwrap(); + sender + .send(String::from_utf8_lossy(&request[..length]).into_owned()) + .unwrap(); + write!( + connection, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + let transport = Transport::new( + Url::parse(&format!("http://{address}")).unwrap(), + Some("owner".into()), + None, + None, + crate::RetryOptions::default(), + "test", + ) + .unwrap(); + let owner = Instance::new( + transport, + Sandbox { + id: "sb-1".into(), + status: SandboxStatus::from("running"), + ..Sandbox::default() + }, + ); + assert!(matches!( + owner.with_access_token(" "), + Err(Error::InvalidArgument(_)) + )); + + let created = owner.create_access_token().await.unwrap(); + assert_eq!(created.token, "skp_sb_first"); + assert!(created.enabled && created.rotated_at.is_none()); + assert_eq!( + owner + .get_access_token() + .await + .unwrap() + .token_hint + .as_deref(), + Some("skp_sb...irst") + ); + let worker = owner.with_access_token(&created.token).unwrap(); + assert!(!Arc::ptr_eq(&owner.data, &worker.data)); + let result = worker + .run_command( + RunCommandRequest { + command: "echo".into(), + arguments: vec!["hello".into()], + ..RunCommandRequest::default() + }, + ExecOptions::default(), + ) + .await + .unwrap(); + assert_eq!(result.result.standard_output, "hello\n"); + assert_eq!( + owner.rotate_access_token().await.unwrap().token, + "skp_sb_second" + ); + assert!(!owner.disable_access_token().await.unwrap().enabled); + + let expected = [ + ("POST /v1/sandboxes/sb%2D1/access-token ", "owner"), + ("GET /v1/sandboxes/sb%2D1/access-token ", "owner"), + ("POST /v1/sandboxes/sb%2D1/exec ", "skp_sb_first"), + ("POST /v1/sandboxes/sb%2D1/access-token/rotate ", "owner"), + ("DELETE /v1/sandboxes/sb%2D1/access-token ", "owner"), + ]; + for (line, credential) in expected { + let request = receiver.recv().unwrap(); + assert!(request.starts_with(line), "{request}"); + assert!( + request + .to_ascii_lowercase() + .contains(&format!("x-api-key: {credential}")) + ); + } + } +} diff --git a/src/models.rs b/src/models.rs index 6194503..1da8357 100644 --- a/src/models.rs +++ b/src/models.rs @@ -354,6 +354,32 @@ Sandbox { #[serde(default)] bandwidth_ingress_bytes: i64, paused_at: Option>, last_resumed_at: Option>, forked_from: Option, auto_pause_after_seconds: Option }); + +/// Plaintext delegated token returned only when created or rotated. +#[derive(Clone, Debug, Deserialize)] +pub struct SandboxAccessTokenCreateResponse { + /// Delegated credential. Store it securely; it cannot be read again. + pub token: String, + /// Whether the token is enabled. + pub enabled: bool, + /// Time the token was first created. + pub created_at: DateTime, + /// Time of the most recent rotation, if any. + pub rotated_at: Option>, +} + +/// Delegated token state without plaintext credential material. +#[derive(Clone, Debug, Deserialize)] +pub struct SandboxAccessTokenMetadata { + /// Whether a delegated token is enabled. + pub enabled: bool, + /// Redacted token hint, when one exists. + pub token_hint: Option, + /// Time the token was first created, when one exists. + pub created_at: Option>, + /// Time of the most recent rotation, if any. + pub rotated_at: Option>, +} model!(/// Buffered command result. CommandResult { #[serde(rename = "stdout")] standard_output: String, #[serde(rename = "stderr")] standard_error: String, exit_code: i32, #[serde(default, rename = "error")] error_message: String }); model!(/// Buffered command response. diff --git a/src/transport.rs b/src/transport.rs index 616751e..58c2e51 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -19,6 +19,14 @@ pub(crate) struct Transport { } impl Transport { + /// Returns a transport with the same connection settings and a separate credential. + pub(crate) fn with_api_key(&self, api_key: String) -> Arc { + Arc::new(Self { + api_key: Some(api_key), + ..self.clone() + }) + } + pub fn new( base_url: Url, api_key: Option, From c2e8e42c0a7a68929a78645b48fe8a41c6483c44 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 01:59:21 +0530 Subject: [PATCH 2/3] fix: set SDK patch version to 0.1.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 5fecc55..38b5f15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "createos" -version = "0.2.0" +version = "0.1.1" edition = "2024" rust-version = "1.85" description = "Async Rust SDK for CreateOS cloud sandboxes" From f71f3c15a4c3326fcaa90d325907d2faf3808727 Mon Sep 17 00:00:00 2001 From: Bhautik Date: Sat, 19 Sep 2026 02:10:44 +0530 Subject: [PATCH 3/3] docs: add Rust changelog for patch release --- CHANGELOG.md | 14 ++++++++++++++ README.md | 1 + 2 files changed, 15 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d8a7a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to the CreateOS Rust SDK are recorded here. + +## Unreleased + +### Added + +- Sandbox access token creation, inspection, rotation, and disabling methods. +- A separate sandbox handle that authenticates runtime operations with a delegated token. + +### Changed + +- Set the next crate version to `0.1.1`; the default user agent follows the crate version. diff --git a/README.md b/README.md index 9cd4365..860ab94 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ propagates asynchronously to peer regions. contains the REST API reference and product guides. - [Rust API reference](https://docs.rs/createos/latest/createos/) is published on docs.rs; run `cargo doc --open` to generate it locally. +- [Changelog](CHANGELOG.md) records SDK changes and the next package version. - [Runnable examples](#examples) cover command execution, files, streaming, ingress, snapshots, networking, templates, managed processes, and desktop use. - [Contributing guide](CONTRIBUTING.md) documents development checks and commit