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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "createos"
version = "0.1.0"
version = "0.1.1"
edition = "2024"
rust-version = "1.85"
description = "Async Rust SDK for CreateOS cloud sandboxes"
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -94,6 +126,7 @@ still disables redirects so credentials cannot be forwarded to another origin.
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
Expand Down
180 changes: 178 additions & 2 deletions src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Self> {
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<SandboxAccessTokenCreateResponse> {
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<SandboxAccessTokenMetadata> {
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<SandboxAccessTokenCreateResponse> {
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<SandboxAccessTokenMetadata> {
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()))
}
Expand Down Expand Up @@ -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}"))
);
}
}
}
26 changes: 26 additions & 0 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,32 @@ Sandbox {
#[serde(default)] bandwidth_ingress_bytes: i64, paused_at: Option<DateTime<Utc>>, last_resumed_at: Option<DateTime<Utc>>,
forked_from: Option<String>, auto_pause_after_seconds: Option<u64>
});

/// 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<Utc>,
/// Time of the most recent rotation, if any.
pub rotated_at: Option<DateTime<Utc>>,
}

/// 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<String>,
/// Time the token was first created, when one exists.
pub created_at: Option<DateTime<Utc>>,
/// Time of the most recent rotation, if any.
pub rotated_at: Option<DateTime<Utc>>,
}
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.
Expand Down
8 changes: 8 additions & 0 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
Arc::new(Self {
api_key: Some(api_key),
..self.clone()
})
}

pub fn new(
base_url: Url,
api_key: Option<String>,
Expand Down
Loading