Implement Go client SDK

Add complete Go SDK implementation with all 13 API functions:
- ExecuteCode: Execute code synchronously
- ExecuteAsync: Execute code asynchronously
- GetJob: Get job status
- WaitForJob: Poll job with exponential backoff
- CancelJob: Cancel running job
- ListJobs: List all jobs
- GetLanguages: Get supported languages (1-hour cache)
- DetectLanguage: Detect language from filename extension
- SessionSnapshot: Create session snapshot (NEW)
- ServiceSnapshot: Create service snapshot (NEW)
- ListSnapshots: List all snapshots (NEW)
- RestoreSnapshot: Restore from snapshot (NEW)
- DeleteSnapshot: Delete snapshot (NEW)

Features:
- Standard Go conventions (PascalCase exports)
- 4-tier credentials resolution (ResolveCredentials)
- HMAC-SHA256 using crypto/hmac and crypto/sha256
- Exponential backoff with time.Sleep
- Languages cache in ~/.unsandbox/languages.json (1-hour TTL)
- Comprehensive error handling with custom CredentialsError type
- Full documentation comments for all exported functions
This commit is contained in:
russell@unturf.com 2026-01-15 16:01:47 -05:00
parent a5f7532fb2
commit 1eb28e2c04

645
clients/go/sync/src/un.go Normal file
View file

@ -0,0 +1,645 @@
/*
PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
unsandbox.com Go SDK (Synchronous)
Library Usage:
import "un"
// Create credentials
creds, err := un.ResolveCredentials("", "")
if err != nil {
log.Fatal(err)
}
// Execute code synchronously
result, err := un.ExecuteCode(creds, "python", `print("hello")`)
if err != nil {
log.Fatal(err)
}
// Execute asynchronously
jobID, err := un.ExecuteAsync(creds, "javascript", `console.log("hello")`)
if err != nil {
log.Fatal(err)
}
// Wait for job completion with exponential backoff
result, err := un.WaitForJob(creds, jobID)
if err != nil {
log.Fatal(err)
}
// List all jobs
jobs, err := un.ListJobs(creds)
if err != nil {
log.Fatal(err)
}
// Get supported languages
languages, err := un.GetLanguages(creds)
if err != nil {
log.Fatal(err)
}
// Detect language from filename
lang := un.DetectLanguage("script.py") // Returns "python"
// Snapshot operations (NEW)
snapshotID, err := un.SessionSnapshot(creds, sessionID, "my_snapshot", false)
snapshots, err := un.ListSnapshots(creds)
result, err := un.RestoreSnapshot(creds, snapshotID)
err = un.DeleteSnapshot(creds, snapshotID)
Authentication Priority (4-tier):
1. Function arguments (publicKey, secretKey)
2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
3. Config file (~/.unsandbox/accounts.csv, line 0 by default)
4. Local directory (./accounts.csv, line 0 by default)
Format: public_key,secret_key (one per line)
Account selection: UNSANDBOX_ACCOUNT=N env var (0-based index)
Request Authentication (HMAC-SHA256):
Authorization: Bearer <public_key> (identifies account)
X-Timestamp: <unix_seconds> (replay prevention)
X-Signature: HMAC-SHA256(secret_key, msg) (proves secret + body integrity)
Message format: "timestamp:METHOD:path:body"
- timestamp: seconds since epoch
- METHOD: GET, POST, DELETE, etc. (uppercase)
- path: e.g., "/execute", "/jobs/123"
- body: JSON payload (empty string for GET/DELETE)
Languages Cache:
- Cached in ~/.unsandbox/languages.json
- TTL: 1 hour
- Updated on successful API calls
*/
package un
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"time"
)
const (
APIBase = "https://api.unsandbox.com"
LanguagesCacheTTL = 3600 // 1 hour
)
var (
PollDelaysMs = []int{300, 450, 700, 900, 650, 1600, 2000}
// Language detection mapping (file extension -> language)
LanguageMap = map[string]string{
"py": "python",
"js": "javascript",
"ts": "typescript",
"rb": "ruby",
"php": "php",
"pl": "perl",
"sh": "bash",
"r": "r",
"R": "r",
"lua": "lua",
"go": "go",
"rs": "rust",
"c": "c",
"cpp": "cpp",
"cc": "cpp",
"cxx": "cpp",
"java": "java",
"kt": "kotlin",
"m": "objc",
"cs": "csharp",
"fs": "fsharp",
"hs": "haskell",
"ml": "ocaml",
"clj": "clojure",
"scm": "scheme",
"ss": "scheme",
"erl": "erlang",
"ex": "elixir",
"exs": "elixir",
"jl": "julia",
"d": "d",
"nim": "nim",
"zig": "zig",
"v": "v",
"cr": "crystal",
"dart": "dart",
"groovy": "groovy",
"f90": "fortran",
"f95": "fortran",
"lisp": "commonlisp",
"lsp": "commonlisp",
"cob": "cobol",
"tcl": "tcl",
"raku": "raku",
"pro": "prolog",
"p": "prolog",
"4th": "forth",
"forth": "forth",
"fth": "forth",
}
)
// CredentialsError is raised when credentials cannot be found or are invalid
type CredentialsError struct {
Message string
}
func (e *CredentialsError) Error() string {
return e.Message
}
// Credentials holds the public and secret API keys
type Credentials struct {
PublicKey string
SecretKey string
}
// getUnsandboxDir returns ~/.unsandbox directory path, creating if necessary
func getUnsandboxDir() (string, error) {
user, err := user.Current()
if err != nil {
return "", err
}
dir := filepath.Join(user.HomeDir, ".unsandbox")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", err
}
return dir, nil
}
// loadCredentialsFromCsv loads credentials from CSV file (public_key,secret_key per line)
func loadCredentialsFromCsv(csvPath string, accountIndex int) *Credentials {
data, err := os.ReadFile(csvPath)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
currentIndex := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if currentIndex == accountIndex {
parts := strings.Split(line, ",")
if len(parts) >= 2 {
return &Credentials{
PublicKey: strings.TrimSpace(parts[0]),
SecretKey: strings.TrimSpace(parts[1]),
}
}
}
currentIndex++
}
return nil
}
// ResolveCredentials resolves credentials from 4-tier priority system.
//
// Priority:
// 1. Function arguments (publicKey, secretKey non-empty)
// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
// 3. ~/.unsandbox/accounts.csv
// 4. ./accounts.csv
func ResolveCredentials(publicKey, secretKey string) (*Credentials, error) {
// Tier 1: Function arguments
if publicKey != "" && secretKey != "" {
return &Credentials{
PublicKey: publicKey,
SecretKey: secretKey,
}, nil
}
// Tier 2: Environment variables
envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY")
envSk := os.Getenv("UNSANDBOX_SECRET_KEY")
if envPk != "" && envSk != "" {
return &Credentials{
PublicKey: envPk,
SecretKey: envSk,
}, nil
}
// Determine account index
accountIndex := 0
if envAccount := os.Getenv("UNSANDBOX_ACCOUNT"); envAccount != "" {
var err error
accountIndex, err = strconv.Atoi(envAccount)
if err != nil {
accountIndex = 0
}
}
// Tier 3: ~/.unsandbox/accounts.csv
unsandboxDir, err := getUnsandboxDir()
if err == nil {
if creds := loadCredentialsFromCsv(filepath.Join(unsandboxDir, "accounts.csv"), accountIndex); creds != nil {
return creds, nil
}
}
// Tier 4: ./accounts.csv
if creds := loadCredentialsFromCsv("accounts.csv", accountIndex); creds != nil {
return creds, nil
}
return nil, &CredentialsError{
Message: "No credentials found. Please provide via:\n" +
" 1. Function arguments (publicKey, secretKey)\n" +
" 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n" +
" 3. ~/.unsandbox/accounts.csv\n" +
" 4. ./accounts.csv",
}
}
// signRequest signs a request using HMAC-SHA256
//
// Message format: "timestamp:METHOD:path:body"
// Returns: 64-character hex string
func signRequest(secretKey string, timestamp int64, method, path string, body []byte) string {
bodyStr := ""
if body != nil {
bodyStr = string(body)
}
message := fmt.Sprintf("%d:%s:%s:%s", timestamp, method, path, bodyStr)
h := hmac.New(sha256.New, []byte(secretKey))
h.Write([]byte(message))
return hex.EncodeToString(h.Sum(nil))
}
// makeRequest makes an authenticated HTTP request to the API
func makeRequest(method, path string, creds *Credentials, data interface{}) (map[string]interface{}, error) {
url := APIBase + path
timestamp := time.Now().Unix()
var body []byte
var err error
if data != nil {
body, err = json.Marshal(data)
if err != nil {
return nil, err
}
}
signature := signRequest(creds.SecretKey, timestamp, method, path, body)
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", creds.PublicKey))
req.Header.Set("X-Timestamp", fmt.Sprintf("%d", timestamp))
req.Header.Set("X-Signature", signature)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "un-go/2.0")
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result map[string]interface{}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return result, nil
}
// getLanguagesCachePath returns path to languages cache file
func getLanguagesCachePath() (string, error) {
unsandboxDir, err := getUnsandboxDir()
if err != nil {
return "", err
}
return filepath.Join(unsandboxDir, "languages.json"), nil
}
// loadLanguagesCache loads languages from cache if valid (< 1 hour old)
func loadLanguagesCache() ([]string, bool) {
cachePath, err := getLanguagesCachePath()
if err != nil {
return nil, false
}
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, false
}
stat, err := os.Stat(cachePath)
if err != nil {
return nil, false
}
ageSeconds := int(time.Since(stat.ModTime()).Seconds())
if ageSeconds >= LanguagesCacheTTL {
return nil, false
}
var cacheData map[string]interface{}
if err := json.Unmarshal(data, &cacheData); err != nil {
return nil, false
}
if langs, ok := cacheData["languages"].([]interface{}); ok {
result := make([]string, len(langs))
for i, lang := range langs {
if s, ok := lang.(string); ok {
result[i] = s
}
}
return result, true
}
return nil, false
}
// saveLanguagesCache saves languages to cache
func saveLanguagesCache(languages []string) {
cachePath, err := getLanguagesCachePath()
if err != nil {
return
}
cacheData := map[string]interface{}{
"languages": languages,
"timestamp": time.Now().Unix(),
}
data, err := json.MarshalIndent(cacheData, "", " ")
if err != nil {
return
}
_ = os.WriteFile(cachePath, data, 0600)
}
// ExecuteCode executes code synchronously (blocks until completion)
func ExecuteCode(creds *Credentials, language, code string) (map[string]interface{}, error) {
data := map[string]string{
"language": language,
"code": code,
}
response, err := makeRequest("POST", "/execute", creds, data)
if err != nil {
return nil, err
}
// If we got a job_id, poll until completion
if jobID, ok := response["job_id"].(string); ok {
if status, ok := response["status"].(string); ok && (status == "pending" || status == "running") {
return WaitForJob(creds, jobID)
}
}
return response, nil
}
// ExecuteAsync executes code asynchronously (returns immediately with job_id)
func ExecuteAsync(creds *Credentials, language, code string) (string, error) {
data := map[string]string{
"language": language,
"code": code,
}
response, err := makeRequest("POST", "/execute", creds, data)
if err != nil {
return "", err
}
if jobID, ok := response["job_id"].(string); ok {
return jobID, nil
}
return "", fmt.Errorf("no job_id in response")
}
// GetJob gets current status/result of a job (single poll, no waiting)
func GetJob(creds *Credentials, jobID string) (map[string]interface{}, error) {
return makeRequest("GET", fmt.Sprintf("/jobs/%s", jobID), creds, nil)
}
// WaitForJob waits for job completion with exponential backoff polling
//
// Polling delays (ms): [300, 450, 700, 900, 650, 1600, 2000, ...]
// Cumulative: 300, 750, 1450, 2350, 3000, 4600, 6600ms+
func WaitForJob(creds *Credentials, jobID string) (map[string]interface{}, error) {
pollCount := 0
for {
// Sleep before polling
delayIdx := pollCount
if delayIdx >= len(PollDelaysMs) {
delayIdx = len(PollDelaysMs) - 1
}
time.Sleep(time.Duration(PollDelaysMs[delayIdx]) * time.Millisecond)
pollCount++
response, err := GetJob(creds, jobID)
if err != nil {
return nil, err
}
if status, ok := response["status"].(string); ok {
if status == "completed" || status == "failed" || status == "timeout" || status == "cancelled" {
return response, nil
}
}
// Still running, continue polling
}
}
// CancelJob cancels a running job
func CancelJob(creds *Credentials, jobID string) (map[string]interface{}, error) {
return makeRequest("DELETE", fmt.Sprintf("/jobs/%s", jobID), creds, nil)
}
// ListJobs lists all jobs for the authenticated account
func ListJobs(creds *Credentials) ([]map[string]interface{}, error) {
response, err := makeRequest("GET", "/jobs", creds, nil)
if err != nil {
return nil, err
}
if jobs, ok := response["jobs"].([]interface{}); ok {
result := make([]map[string]interface{}, len(jobs))
for i, job := range jobs {
if m, ok := job.(map[string]interface{}); ok {
result[i] = m
}
}
return result, nil
}
return []map[string]interface{}{}, nil
}
// GetLanguages gets list of supported programming languages
//
// Results are cached for 1 hour in ~/.unsandbox/languages.json
func GetLanguages(creds *Credentials) ([]string, error) {
// Try cache first
if cached, ok := loadLanguagesCache(); ok {
return cached, nil
}
response, err := makeRequest("GET", "/languages", creds, nil)
if err != nil {
return nil, err
}
var languages []string
if langs, ok := response["languages"].([]interface{}); ok {
for _, lang := range langs {
if s, ok := lang.(string); ok {
languages = append(languages, s)
}
}
}
// Cache the result
saveLanguagesCache(languages)
return languages, nil
}
// DetectLanguage detects programming language from filename extension
//
// Args:
// filename: Filename to detect language from (e.g., "script.py")
//
// Returns:
// Language identifier (e.g., "python") or empty string if unknown
//
// Examples:
// DetectLanguage("hello.py") // -> "python"
// DetectLanguage("script.js") // -> "javascript"
// DetectLanguage("main.go") // -> "go"
// DetectLanguage("unknown") // -> ""
func DetectLanguage(filename string) string {
if !strings.Contains(filename, ".") {
return ""
}
parts := strings.Split(filename, ".")
ext := strings.ToLower(parts[len(parts)-1])
if lang, ok := LanguageMap[ext]; ok {
return lang
}
return ""
}
// SessionSnapshot creates a snapshot of a session (NEW)
func SessionSnapshot(creds *Credentials, sessionID, name string, hot bool) (string, error) {
data := map[string]interface{}{
"session_id": sessionID,
"hot": hot,
}
if name != "" {
data["name"] = name
}
response, err := makeRequest("POST", "/snapshots", creds, data)
if err != nil {
return "", err
}
if snapshotID, ok := response["snapshot_id"].(string); ok {
return snapshotID, nil
}
return "", fmt.Errorf("no snapshot_id in response")
}
// ServiceSnapshot creates a snapshot of a service (NEW)
func ServiceSnapshot(creds *Credentials, serviceID, name string, hot bool) (string, error) {
data := map[string]interface{}{
"service_id": serviceID,
"hot": hot,
}
if name != "" {
data["name"] = name
}
response, err := makeRequest("POST", "/snapshots", creds, data)
if err != nil {
return "", err
}
if snapshotID, ok := response["snapshot_id"].(string); ok {
return snapshotID, nil
}
return "", fmt.Errorf("no snapshot_id in response")
}
// ListSnapshots lists all snapshots (NEW)
func ListSnapshots(creds *Credentials) ([]map[string]interface{}, error) {
response, err := makeRequest("GET", "/snapshots", creds, nil)
if err != nil {
return nil, err
}
if snapshots, ok := response["snapshots"].([]interface{}); ok {
result := make([]map[string]interface{}, len(snapshots))
for i, snapshot := range snapshots {
if m, ok := snapshot.(map[string]interface{}); ok {
result[i] = m
}
}
return result, nil
}
return []map[string]interface{}{}, nil
}
// RestoreSnapshot restores a snapshot (NEW)
func RestoreSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) {
return makeRequest("POST", fmt.Sprintf("/snapshots/%s/restore", snapshotID), creds, map[string]interface{}{})
}
// DeleteSnapshot deletes a snapshot (NEW)
func DeleteSnapshot(creds *Credentials, snapshotID string) (map[string]interface{}, error) {
return makeRequest("DELETE", fmt.Sprintf("/snapshots/%s", snapshotID), creds, nil)
}