Console Playground

CLI

Fast command-line client for code execution and interactive sessions. 42+ languages, 30+ shells/REPLs.

Official OpenAPI Swagger Docs ↗

Quick Start — JavaScript

# Download + setup
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/javascript/sync/src/un.js && chmod +x un.js && ln -sf un.js un
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Run code
./un script.js

Downloads

Install Guide →
Static Binary
Linux x86_64 (5.3MB)
un
JavaScript SDK
un.js (90.8 KB)
Download

Features

  • 42+ languages - Python, JS, Go, Rust, C++, Java...
  • Sessions - 30+ shells/REPLs, tmux persistence
  • Files - Upload files, collect artifacts
  • Services - Persistent containers with domains
  • Snapshots - Point-in-time backups
  • Images - Publish, share, transfer

Integration Quickstart ⚡

Add unsandbox superpowers to your existing JavaScript app:

1
Download
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/javascript/sync/src/un.js
2
Set API Keys
# Option A: Environment variables
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"

# Option B: Config file (persistent)
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
3
Hello World
// In your Node.js app:
const { executeCode } = require('./un.js');

const result = await executeCode("javascript", "console.log('Hello from JavaScript running on unsandbox!')");
console.log(result.stdout);  // Hello from JavaScript running on unsandbox!
Demo cooldown: s
stdout:

                      
JSON Response:

                      
4
Run
node myapp.js
Source Code 📄 (3225 lines)
MD5: 494d2b86aabde485c3e83bf1cf92980e SHA256: d42260eec0f547662061a606ffaa6bc3e43c8fb70c092ff4acfe0d81bec42480
#!/usr/bin/env node
/**
 * PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
 *
 * unsandbox.com JavaScript SDK (Synchronous/Async)
 * Isomorphic: Works in Node.js (CLI + SDK) and Browser environments
 *
 * Library Usage (ES Modules):
 *   import {
 *     // Code execution
 *     executeCode, executeAsync, getJob, waitForJob, cancelJob, listJobs,
 *     getLanguages, detectLanguage,
 *     // Session management
 *     listSessions, getSession, createSession, deleteSession,
 *     freezeSession, unfreezeSession, boostSession, unboostSession, shellSession,
 *     // Service management
 *     listServices, createService, getService, updateService, deleteService,
 *     freezeService, unfreezeService, lockService, unlockService, setUnfreezeOnDemand, setShowFreezePage,
 *     getServiceLogs, getServiceEnv, setServiceEnv, deleteServiceEnv,
 *     exportServiceEnv, redeployService, executeInService,
 *     // Snapshot management
 *     sessionSnapshot, serviceSnapshot, listSnapshots, restoreSnapshot,
 *     deleteSnapshot, lockSnapshot, unlockSnapshot, cloneSnapshot,
 *     // Images API (LXD container images)
 *     imagePublish, listImages, getImage, deleteImage,
 *     lockImage, unlockImage, setImageVisibility,
 *     grantImageAccess, revokeImageAccess, listImageTrusted,
 *     transferImage, spawnFromImage, cloneImage,
 *     // Key validation
 *     validateKeys,
 *   } from './un.js';
 *
 *   // Execute code asynchronously (returns Promise)
 *   const result = await executeCode('python', 'print("hello")', publicKey, secretKey);
 *   const jobId = await executeAsync('javascript', 'console.log("hello")', publicKey, secretKey);
 *   const result = await waitForJob(jobId, publicKey, secretKey);
 *
 * Authentication Priority (5-tier):
 *   1. Function arguments (publicKey, secretKey)
 *   2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) [Node.js]
 *   3. Encrypted vault or localStorage [Browser] (vault preferred if CryptoJS available)
 *   4. ~/.unsandbox/accounts.csv [Node.js]
 *   5. ./accounts.csv [Node.js]
 *
 * Request Authentication (HMAC-SHA256):
 *   Authorization: Bearer <publicKey>
 *   X-Timestamp: <unixSeconds>
 *   X-Signature: HMAC-SHA256(secretKey, "timestamp:METHOD:path:body")
 *
 * Languages Cache:
 *   - Cached in ~/.unsandbox/languages.json (Node.js only)
 *   - TTL: 1 hour
 *   - Updated on successful API calls
 *
 * Browser Usage:
 *   - Import as ES module: <script type="module">
 *   - Credentials can be stored in encrypted vault (requires CryptoJS):
 *       UnsandboxVault.createVault('mypassword');
 *       UnsandboxVault.saveKeysToVault(vaultId, [{publicKey, secretKey}], 'mypassword');
 *   - Or configure via plain localStorage (legacy):
 *       localStorage.setItem('useUnsandbox', 'true');
 *       localStorage.setItem('unsandboxPublicKey', 'unsb-pk-...');
 *       localStorage.setItem('unsandboxSecretKey', 'unsb-sk-...');
 *   - Or pass credentials directly to functions
 *   - Uses Web Crypto API for HMAC-SHA256 signing
 */

// Environment detection for isomorphic support (Node.js + Browser)
const IS_BROWSER = typeof window !== 'undefined' && typeof window.document !== 'undefined';
const IS_NODE = typeof process !== 'undefined' && process.versions != null && process.versions.node != null;

// Conditional imports for Node.js (null in browser)
let crypto = null;
let fs = null;
let path = null;

if (IS_NODE) {
  // Dynamic imports for Node.js
  crypto = await import('crypto').then(m => m.default || m);
  fs = await import('fs').then(m => m.default || m);
  path = await import('path').then(m => m.default || m);
}

const API_BASE = 'https://api.unsandbox.com';
const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000];
const LANGUAGES_CACHE_TTL = 3600; // 1 hour

// ============================================================================
// Vault System for Encrypted Credential Storage [Browser only]
// Requires CryptoJS library for AES encryption
// ============================================================================

/**
 * UnsandboxVault - Encrypted credential storage using AES-256
 * Compatible with unsandbox.com portal vault format
 */
const UnsandboxVault = {
  /**
   * Check if CryptoJS is available for encryption
   */
  isAvailable() {
    return IS_BROWSER && typeof CryptoJS !== 'undefined';
  },

  /**
   * Get or create stable device salt (32 bytes, stored in localStorage)
   */
  getDeviceSalt() {
    if (!IS_BROWSER) return null;
    let salt = localStorage.getItem('unsandbox_device_salt');
    if (!salt) {
      const randomBytes = CryptoJS.lib.WordArray.random(32);
      salt = randomBytes.toString();
      localStorage.setItem('unsandbox_device_salt', salt);
    }
    return salt;
  },

  /**
   * Encrypt data with password using AES-256
   */
  encrypt(data, password) {
    if (!this.isAvailable()) return null;
    try {
      return CryptoJS.AES.encrypt(JSON.stringify(data), password).toString();
    } catch (e) {
      return null;
    }
  },

  /**
   * Decrypt data with password
   */
  decrypt(encryptedData, password) {
    if (!this.isAvailable()) return null;
    try {
      const bytes = CryptoJS.AES.decrypt(encryptedData, password);
      const decryptedStr = bytes.toString(CryptoJS.enc.Utf8);
      if (!decryptedStr) return null;
      return JSON.parse(decryptedStr);
    } catch (e) {
      return null;
    }
  },

  /**
   * Generate vault ID from password (deterministic per device)
   */
  getVaultId(password) {
    if (!this.isAvailable()) return null;
    const salt = this.getDeviceSalt();
    return CryptoJS.SHA256(password + salt).toString();
  },

  /**
   * Get all vaults from localStorage
   */
  getAllVaults() {
    if (!IS_BROWSER) return {};
    try {
      const vaultsJson = localStorage.getItem('unsandbox_vaults');
      return vaultsJson ? JSON.parse(vaultsJson) : {};
    } catch (e) {
      return {};
    }
  },

  /**
   * Save all vaults to localStorage
   */
  saveAllVaults(vaults) {
    if (!IS_BROWSER) return;
    localStorage.setItem('unsandbox_vaults', JSON.stringify(vaults));
  },

  /**
   * Check if any vaults exist
   */
  hasVaults() {
    return Object.keys(this.getAllVaults()).length > 0;
  },

  /**
   * Unlock vault with password
   * Returns: { success, vaultId, keys, activeKeyIndex, error }
   */
  unlockVault(password) {
    if (!this.isAvailable()) {
      return { success: false, error: 'CryptoJS not available' };
    }

    const vaultId = this.getVaultId(password);
    const vaults = this.getAllVaults();

    if (!vaults[vaultId]) {
      return { success: false, error: 'No vault found for this password' };
    }

    const decrypted = this.decrypt(vaults[vaultId].encrypted_keys, password);
    if (!decrypted) {
      return { success: false, error: 'Failed to decrypt vault' };
    }

    const activeIndex = vaults[vaultId].active_key_index || 0;
    return { success: true, vaultId, keys: decrypted, activeKeyIndex: activeIndex };
  },

  /**
   * Create new vault with password
   * Returns: { success, vaultId, error }
   */
  createVault(password) {
    if (!this.isAvailable()) {
      return { success: false, error: 'CryptoJS not available' };
    }
    if (password.length < 8) {
      return { success: false, error: 'Password must be at least 8 characters' };
    }

    const vaultId = this.getVaultId(password);
    const vaults = this.getAllVaults();

    if (vaults[vaultId]) {
      // Vault exists, try to unlock instead
      const decrypted = this.decrypt(vaults[vaultId].encrypted_keys, password);
      if (decrypted) {
        return { success: true, vaultId, keys: decrypted, unlocked: true };
      }
      return { success: false, error: 'Vault exists but failed to decrypt' };
    }

    const encrypted = this.encrypt([], password);
    if (!encrypted) {
      return { success: false, error: 'Failed to encrypt vault' };
    }

    vaults[vaultId] = {
      encrypted_keys: encrypted,
      created_at: new Date().toISOString(),
      active_key_index: 0
    };

    this.saveAllVaults(vaults);
    return { success: true, vaultId };
  },

  /**
   * Save keys to vault
   */
  saveKeysToVault(vaultId, keys, password, activeIndex = 0) {
    if (!this.isAvailable()) return false;

    const vaults = this.getAllVaults();
    if (!vaults[vaultId]) return false;

    const encrypted = this.encrypt(keys, password);
    if (!encrypted) return false;

    vaults[vaultId].encrypted_keys = encrypted;
    vaults[vaultId].active_key_index = activeIndex;
    vaults[vaultId].updated_at = new Date().toISOString();

    this.saveAllVaults(vaults);
    return true;
  },

  /**
   * Get active key from unlocked vault via global helper (if available)
   * This integrates with unsandbox.com portal's vault UI
   */
  getActiveKey() {
    if (!IS_BROWSER) return null;
    // Check if portal's getActiveApiKey function is available
    if (typeof window.getActiveApiKey === 'function') {
      return window.getActiveApiKey();
    }
    return null;
  }
};

// Make vault available globally in browser
if (IS_BROWSER) {
  window.UnsandboxVault = UnsandboxVault;
}

class CredentialsError extends Error {
  constructor(message) {
    super(message);
    this.name = 'CredentialsError';
  }
}

/**
 * Get ~/.unsandbox directory path, creating if necessary. [Node.js only]
 */
function getUnsandboxDir() {
  if (!IS_NODE) return null;
  const home = process.env.HOME || process.env.USERPROFILE;
  if (!home) {
    throw new Error('Could not determine home directory');
  }
  const dir = path.join(home, '.unsandbox');
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
  }
  return dir;
}

/**
 * Load credentials from CSV file (public_key,secret_key per line). [Node.js only]
 */
function loadCredentialsFromCsv(csvPath, accountIndex = 0) {
  if (!IS_NODE || !fs) return null;
  if (!fs.existsSync(csvPath)) {
    return null;
  }

  try {
    const lines = fs.readFileSync(csvPath, 'utf-8').split('\n');
    let currentIndex = 0;
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith('#')) {
        continue;
      }
      if (currentIndex === accountIndex) {
        const parts = trimmed.split(',');
        if (parts.length >= 2) {
          return [parts[0].trim(), parts[1].trim()];
        }
      }
      currentIndex++;
    }
  } catch (e) {
    // Ignore read errors
  }

  return null;
}

/**
 * Load credentials from vault. [Browser only]
 * Priority:
 *   1. UnsandboxVault (own encrypted vault, requires unlocked via portal UI)
 *   2. External vaults (window.UncloseVault for uncloseai.com integration)
 */
function loadCredentialsFromStorage() {
  if (!IS_BROWSER) return null;

  // Priority 1: Get active key from UnsandboxVault (requires portal UI to unlock)
  try {
    const activeKey = UnsandboxVault.getActiveKey();
    if (activeKey && activeKey.publicKey && (activeKey.secretKey || activeKey.key)) {
      return [activeKey.publicKey, activeKey.secretKey || activeKey.key];
    }
  } catch (e) {
    // Vault not available or not unlocked
  }

  // Priority 2: Check external vaults (e.g., UncloseVault from uncloseai.com)
  try {
    if (window.UncloseVault && window.UncloseVault.isUnlocked()) {
      const useUnsandbox = window.UncloseVault.get('useUnsandbox', false);
      if (useUnsandbox) {
        const publicKey = window.UncloseVault.get('unsandboxPublicKey');
        const secretKey = window.UncloseVault.get('unsandboxSecretKey');
        if (publicKey && secretKey) {
          return [publicKey, secretKey];
        }
      }
    }
  } catch (e) {
    // External vault not available
  }

  return null;
}

/**
 * Resolve credentials from 5-tier priority system.
 *
 * Priority:
 *   1. Function arguments
 *   2. Environment variables (Node.js)
 *   3. localStorage (Browser)
 *   4. ~/.unsandbox/accounts.csv (Node.js)
 *   5. ./accounts.csv (Node.js)
 */
function resolveCredentials(publicKey, secretKey, accountIndex) {
  // Tier 1: Function arguments
  if (publicKey && secretKey) {
    return [publicKey, secretKey];
  }

  // Tier 2: Environment variables (Node.js only)
  if (IS_NODE) {
    const envPk = process.env.UNSANDBOX_PUBLIC_KEY;
    const envSk = process.env.UNSANDBOX_SECRET_KEY;
    if (envPk && envSk) {
      return [envPk, envSk];
    }
  }

  // Tier 3: localStorage (Browser only)
  if (IS_BROWSER) {
    const storageCreds = loadCredentialsFromStorage();
    if (storageCreds) {
      return storageCreds;
    }
  }

  // Tier 4 & 5: File-based credentials (Node.js only)
  if (IS_NODE && fs && path) {
    // Determine account index
    if (accountIndex === undefined) {
      accountIndex = parseInt(process.env.UNSANDBOX_ACCOUNT || '0', 10);
    }

    // Tier 4: ~/.unsandbox/accounts.csv
    try {
      const unsandboxDir = getUnsandboxDir();
      const creds = loadCredentialsFromCsv(path.join(unsandboxDir, 'accounts.csv'), accountIndex);
      if (creds) {
        return creds;
      }
    } catch (e) {
      // Continue to next tier
    }

    // Tier 5: ./accounts.csv
    const creds = loadCredentialsFromCsv('accounts.csv', accountIndex);
    if (creds) {
      return creds;
    }
  }

  const envHint = IS_NODE
    ? '  2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)\n' +
      '  3. ~/.unsandbox/accounts.csv\n' +
      '  4. ./accounts.csv'
    : '  2. localStorage (enable "Use unsandbox.com API keys" in settings)';

  throw new CredentialsError(
    'No credentials found. Please provide via:\n' +
    '  1. Function arguments (publicKey, secretKey)\n' +
    envHint
  );
}

/**
 * Sign a request using HMAC-SHA256.
 * Isomorphic: uses Node.js crypto or Web Crypto API.
 *
 * Message format: "timestamp:METHOD:path:body"
 * Returns: 64-character hex string
 */
async function signRequest(secretKey, timestamp, method, urlPath, body) {
  const bodyStr = body || '';
  const message = `${timestamp}:${method}:${urlPath}:${bodyStr}`;

  if (IS_NODE && crypto) {
    // Node.js: synchronous HMAC
    return crypto
      .createHmac('sha256', secretKey)
      .update(message)
      .digest('hex');
  } else {
    // Browser: Web Crypto API (async)
    const encoder = new TextEncoder();
    const keyData = encoder.encode(secretKey);
    const messageData = encoder.encode(message);

    const cryptoKey = await window.crypto.subtle.importKey(
      'raw',
      keyData,
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );

    const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, messageData);
    const hashArray = Array.from(new Uint8Array(signature));
    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
  }
}

/**
 * Sleep for a specified number of milliseconds.
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * Make an authenticated HTTP request to the API using native fetch.
 *
 * Returns: Promise<Object> (parsed JSON response)
 * Throws: Error on network errors or non-JSON response
 */
async function makeRequest(method, urlPath, publicKey, secretKey, data) {
  const url = `${API_BASE}${urlPath}`;
  const timestamp = Math.floor(Date.now() / 1000);
  const body = data ? JSON.stringify(data) : '';

  const signature = await signRequest(secretKey, timestamp, method, urlPath, body || null);

  const headers = {
    'Authorization': `Bearer ${publicKey}`,
    'X-Timestamp': timestamp.toString(),
    'X-Signature': signature,
    'Content-Type': 'application/json',
    'User-Agent': 'un-js/2.0',
  };

  const options = {
    method,
    headers,
    signal: AbortSignal.timeout(120000), // 120 seconds timeout
  };

  // Add body for methods that support it
  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) {
    options.body = body;
  }

  const response = await fetch(url, options);

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`HTTP ${response.status}: ${text}`);
  }

  return response.json();
}

/**
 * Make an authenticated HTTP request with sudo OTP challenge handling.
 *
 * If the server returns 428 (Precondition Required), prompts for OTP
 * and retries the request with X-Sudo-OTP and X-Sudo-Challenge headers.
 *
 * Used for destructive operations: service destroy/unlock, snapshot delete/unlock,
 * image delete/unlock.
 */
async function makeRequestWithSudo(method, urlPath, publicKey, secretKey, data) {
  const url = `${API_BASE}${urlPath}`;
  const timestamp = Math.floor(Date.now() / 1000);
  const body = data ? JSON.stringify(data) : '';

  const signature = await signRequest(secretKey, timestamp, method, urlPath, body || null);

  const headers = {
    'Authorization': `Bearer ${publicKey}`,
    'X-Timestamp': timestamp.toString(),
    'X-Signature': signature,
    'Content-Type': 'application/json',
    'User-Agent': 'un-js/2.0',
  };

  const options = {
    method,
    headers,
    signal: AbortSignal.timeout(120000),
  };

  if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) {
    options.body = body;
  }

  let response = await fetch(url, options);

  // Handle 428 sudo OTP challenge
  if (response.status === 428) {
    let challengeId = '';
    try {
      const challengeData = await response.json();
      challengeId = challengeData.challenge_id || '';
    } catch (e) {
      // Ignore JSON parse errors
    }

    console.error('\x1b[33mConfirmation required. Check your email for a one-time code.\x1b[0m');

    // Prompt for OTP (Node.js only)
    if (!IS_NODE) {
      throw new Error('Sudo OTP challenge not supported in browser environment');
    }

    const readline = require('readline');
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stderr
    });

    const otp = await new Promise((resolve) => {
      rl.question('Enter OTP: ', (answer) => {
        rl.close();
        resolve(answer.trim());
      });
    });

    if (!otp) {
      throw new Error('Operation cancelled');
    }

    // Retry with sudo headers
    const retryTimestamp = Math.floor(Date.now() / 1000);
    const retrySignature = await signRequest(secretKey, retryTimestamp, method, urlPath, body || null);

    const retryHeaders = {
      'Authorization': `Bearer ${publicKey}`,
      'X-Timestamp': retryTimestamp.toString(),
      'X-Signature': retrySignature,
      'Content-Type': 'application/json',
      'User-Agent': 'un-js/2.0',
      'X-Sudo-OTP': otp,
      'X-Sudo-Challenge': challengeId,
    };

    const retryOptions = {
      method,
      headers: retryHeaders,
      signal: AbortSignal.timeout(120000),
    };

    if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) && body) {
      retryOptions.body = body;
    }

    response = await fetch(url, retryOptions);
  }

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`HTTP ${response.status}: ${text}`);
  }

  return response.json();
}

/**
 * Get path to languages cache file. [Node.js only]
 */
function getLanguagesCachePath() {
  if (!IS_NODE) return null;
  const dir = getUnsandboxDir();
  if (!dir) return null;
  return path.join(dir, 'languages.json');
}

/**
 * Load languages from cache if valid (< 1 hour old). [Node.js only]
 */
function loadLanguagesCache() {
  if (!IS_NODE || !fs) return null;
  try {
    const cachePath = getLanguagesCachePath();
    if (!cachePath || !fs.existsSync(cachePath)) {
      return null;
    }

    const stat = fs.statSync(cachePath);
    const ageSeconds = (Date.now() - stat.mtimeMs) / 1000;
    if (ageSeconds >= LANGUAGES_CACHE_TTL) {
      return null;
    }

    const data = JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
    return data.languages || null;
  } catch (e) {
    return null;
  }
}

/**
 * Save languages to cache. [Node.js only]
 */
function saveLanguagesCache(languages) {
  if (!IS_NODE || !fs) return;
  try {
    const cachePath = getLanguagesCachePath();
    if (!cachePath) return;
    const data = {
      languages,
      timestamp: Math.floor(Date.now() / 1000),
    };
    fs.writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf-8');
  } catch (e) {
    // Cache failures are non-fatal
  }
}

/**
 * Execute code synchronously (awaits until completion).
 *
 * Args:
 *   language: Programming language (e.g., "python", "javascript", "go")
 *   code: Source code to execute
 *   publicKey: Optional API key (uses credentials resolution if not provided)
 *   secretKey: Optional API secret (uses credentials resolution if not provided)
 *
 * Returns: Promise<Object> with stdout, stderr, exit code, etc.
 */
async function executeCode(language, code, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('POST', '/execute', publicKey, secretKey, {
    language,
    code,
  });

  // If we got a job_id, poll until completion
  const jobId = response.job_id;
  const status = response.status;

  if (jobId && ['pending', 'running'].includes(status)) {
    return waitForJob(jobId, publicKey, secretKey);
  }

  return response;
}

/**
 * Execute code asynchronously (returns immediately with job_id).
 *
 * Returns: Promise<string> (job ID)
 */
async function executeAsync(language, code, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('POST', '/execute', publicKey, secretKey, {
    language,
    code,
  });
  return response.job_id;
}

/**
 * Get current status/result of a job (single poll, no waiting).
 *
 * Returns: Promise<Object> (job response)
 */
async function getJob(jobId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/jobs/${jobId}`, publicKey, secretKey);
}

/**
 * Wait 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+
 *
 * Returns: Promise<Object> (final job result when status is terminal)
 */
async function waitForJob(jobId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  let pollCount = 0;

  while (true) {
    // Sleep before polling
    const delayIdx = Math.min(pollCount, POLL_DELAYS_MS.length - 1);
    await new Promise((resolve) => setTimeout(resolve, POLL_DELAYS_MS[delayIdx]));
    pollCount++;

    const response = await getJob(jobId, publicKey, secretKey);
    const status = response.status;

    if (['completed', 'failed', 'timeout', 'cancelled'].includes(status)) {
      return response;
    }

    // Still running, continue polling
  }
}

/**
 * Cancel a running job.
 *
 * Returns: Promise<Object> (cancellation confirmation)
 */
async function cancelJob(jobId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('DELETE', `/jobs/${jobId}`, publicKey, secretKey);
}

/**
 * List all jobs for the authenticated account.
 *
 * Returns: Promise<Array> (list of job dicts)
 */
async function listJobs(publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', '/jobs', publicKey, secretKey);
  return response.jobs || [];
}

/**
 * Get list of supported programming languages.
 *
 * Results are cached for 1 hour in ~/.unsandbox/languages.json
 *
 * Returns: Promise<Array> (list of language identifiers)
 */
async function getLanguages(publicKey, secretKey) {
  // Try cache first
  const cached = loadLanguagesCache();
  if (cached) {
    return cached;
  }

  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', '/languages', publicKey, secretKey);
  const languages = response.languages || [];

  // Cache the result
  saveLanguagesCache(languages);
  return languages;
}

/**
 * Language detection mapping (file extension -> language).
 */
const LANGUAGE_MAP = {
  py: 'python',
  js: 'javascript',
  ts: 'typescript',
  rb: 'ruby',
  php: 'php',
  pl: 'perl',
  sh: 'bash',
  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',
};

/**
 * Detect programming language from filename extension.
 *
 * Args:
 *   filename: Filename to detect language from (e.g., "script.py")
 *
 * Returns:
 *   Language identifier (e.g., "python") or null if unknown
 *
 * Examples:
 *   detectLanguage("hello.py")   // -> "python"
 *   detectLanguage("script.js")  // -> "javascript"
 *   detectLanguage("main.go")    // -> "go"
 *   detectLanguage("unknown")    // -> null
 */
function detectLanguage(filename) {
  if (!filename || !filename.includes('.')) {
    return null;
  }

  const ext = filename.split('.').pop().toLowerCase();
  return LANGUAGE_MAP[ext] || null;
}

/**
 * Create a snapshot of a session (NEW).
 *
 * Returns: Promise<string> (snapshot ID)
 */
async function sessionSnapshot(sessionId, publicKey, secretKey, name, hot = false) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {
    session_id: sessionId,
    hot,
  };
  if (name) {
    data.name = name;
  }

  const response = await makeRequest('POST', '/snapshots', publicKey, secretKey, data);
  return response.snapshot_id;
}

/**
 * Create a snapshot of a service (NEW).
 *
 * Returns: Promise<string> (snapshot ID)
 */
async function serviceSnapshot(serviceId, publicKey, secretKey, name, hot = false) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {
    service_id: serviceId,
    hot,
  };
  if (name) {
    data.name = name;
  }

  const response = await makeRequest('POST', '/snapshots', publicKey, secretKey, data);
  return response.snapshot_id;
}

/**
 * List all snapshots.
 *
 * Returns: Promise<Array> (list of snapshot dicts)
 */
async function listSnapshots(publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', '/snapshots', publicKey, secretKey);
  return response.snapshots || [];
}

/**
 * Get details of a specific snapshot.
 *
 * Args:
 *   snapshotId: Snapshot ID to get details for
 *
 * Returns: Promise<Object> (snapshot details with id, name, type, source_id, etc.)
 */
async function getSnapshot(snapshotId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/snapshots/${snapshotId}`, publicKey, secretKey);
}

/**
 * Restore a snapshot.
 *
 * Returns: Promise<Object> (response with restored resource info)
 */
async function restoreSnapshot(snapshotId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/snapshots/${snapshotId}/restore`, publicKey, secretKey, {});
}

/**
 * Delete a snapshot (NEW).
 *
 * Returns: Promise<Object> (deletion confirmation)
 */
async function deleteSnapshot(snapshotId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('DELETE', `/snapshots/${snapshotId}`, publicKey, secretKey);
}

// ============================================================================
// Session Management Functions
// ============================================================================

/**
 * List all active sessions.
 *
 * Returns: Promise<Array> (list of session objects)
 */
async function listSessions(publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', '/sessions', publicKey, secretKey);
  return response.sessions || [];
}

/**
 * Get details of a specific session.
 *
 * Args:
 *   sessionId: Session ID to retrieve
 *
 * Returns: Promise<Object> (session details)
 */
async function getSession(sessionId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/sessions/${sessionId}`, publicKey, secretKey);
}

/**
 * Create a new interactive session.
 *
 * Args:
 *   language: Optional programming language/shell (default: "bash")
 *   opts: Optional settings:
 *     - networkMode: "zerotrust" (default) or "semitrusted"
 *     - shell: Shell to use (e.g., "python3", "bash")
 *     - multiplexer: "tmux", "screen", or null
 *     - vcpu: Number of vCPUs (1-8)
 *     - ttl: Time-to-live in seconds
 *
 * Returns: Promise<Object> (session info with session_id, container_name)
 */
async function createSession(language, opts = {}, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {
    network_mode: opts.networkMode || 'zerotrust',
    ttl: opts.ttl || 3600,
  };
  if (language) data.shell = language;
  if (opts.shell) data.shell = opts.shell;
  if (opts.multiplexer) data.multiplexer = opts.multiplexer;
  if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu;

  return makeRequest('POST', '/sessions', publicKey, secretKey, data);
}

/**
 * Delete/terminate a session.
 *
 * Args:
 *   sessionId: Session ID to terminate
 *
 * Returns: Promise<Object> (deletion confirmation)
 */
async function deleteSession(sessionId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('DELETE', `/sessions/${sessionId}`, publicKey, secretKey);
}

/**
 * Freeze a session (pause execution, preserve state).
 *
 * Args:
 *   sessionId: Session ID to freeze
 *
 * Returns: Promise<Object> (freeze confirmation)
 */
async function freezeSession(sessionId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/sessions/${sessionId}/freeze`, publicKey, secretKey, {});
}

/**
 * Unfreeze a session (resume execution).
 *
 * Args:
 *   sessionId: Session ID to unfreeze
 *
 * Returns: Promise<Object> (unfreeze confirmation)
 */
async function unfreezeSession(sessionId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/sessions/${sessionId}/unfreeze`, publicKey, secretKey, {});
}

/**
 * Boost a session's resources (increase vCPU, memory).
 *
 * Args:
 *   sessionId: Session ID to boost
 *   vcpu: Number of vCPUs (default: 2)
 *
 * Returns: Promise<Object> (boost confirmation)
 */
async function boostSession(sessionId, vcpu = 2, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/sessions/${sessionId}/boost`, publicKey, secretKey, { vcpu });
}

/**
 * Unboost a session (return to base resources).
 *
 * Args:
 *   sessionId: Session ID to unboost
 *
 * Returns: Promise<Object> (unboost confirmation)
 */
async function unboostSession(sessionId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/sessions/${sessionId}/unboost`, publicKey, secretKey, {});
}

/**
 * Execute a shell command in a session.
 *
 * Note: This initiates a WebSocket connection for interactive shell.
 * For simple command execution, this sends the command via the shell endpoint.
 *
 * Args:
 *   sessionId: Session ID
 *   command: Command to execute
 *
 * Returns: Promise<Object> (command result)
 */
async function shellSession(sessionId, command, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/sessions/${sessionId}/shell`, publicKey, secretKey, { command });
}

// ============================================================================
// Service Management Functions
// ============================================================================

/**
 * List all services.
 *
 * Returns: Promise<Array> (list of service objects)
 */
async function listServices(publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', '/services', publicKey, secretKey);
  return response.services || [];
}

/**
 * Create a new service (persistent container).
 *
 * Args:
 *   name: Service name
 *   ports: Array of port numbers to expose (e.g., [80, 443])
 *   bootstrap: Bootstrap script content or URL
 *   opts: Optional settings:
 *     - networkMode: "zerotrust" or "semitrusted"
 *     - vcpu: Number of vCPUs (1-8)
 *     - domains: Array of custom domains
 *     - serviceType: Service type for SRV records (minecraft, mumble, etc.)
 *     - unfreezeOnDemand: If true, frozen services wake automatically on HTTP traffic
 *
 * Returns: Promise<Object> (service info with service_id)
 */
async function createService(name, ports, bootstrap, opts = {}, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {};
  if (name) data.name = name;
  if (ports && ports.length > 0) data.ports = ports;
  if (bootstrap) {
    // If bootstrap starts with http, treat as URL, otherwise as content
    if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) {
      data.bootstrap = bootstrap;
    } else {
      data.bootstrap_content = bootstrap;
    }
  }
  if (opts.networkMode) data.network_mode = opts.networkMode;
  if (opts.vcpu && opts.vcpu > 1) data.vcpu = opts.vcpu;
  if (opts.domains) data.custom_domains = opts.domains;
  if (opts.serviceType) data.service_type = opts.serviceType;
  if (opts.unfreezeOnDemand) data.unfreeze_on_demand = true;

  return makeRequest('POST', '/services', publicKey, secretKey, data);
}

/**
 * Get details of a specific service.
 *
 * Args:
 *   serviceId: Service ID to retrieve
 *
 * Returns: Promise<Object> (service details)
 */
async function getService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/services/${serviceId}`, publicKey, secretKey);
}

/**
 * Update a service (resize vCPU/memory).
 *
 * Args:
 *   serviceId: Service ID to update
 *   opts: Update options:
 *     - vcpu: New vCPU count (1-8)
 *
 * Returns: Promise<Object> (update confirmation)
 */
async function updateService(serviceId, opts = {}, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {};
  if (opts.vcpu) data.vcpu = opts.vcpu;
  return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, data);
}

/**
 * Delete/destroy a service.
 *
 * Args:
 *   serviceId: Service ID to destroy
 *
 * Returns: Promise<Object> (deletion confirmation)
 */
async function deleteService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('DELETE', `/services/${serviceId}`, publicKey, secretKey);
}

/**
 * Freeze a service (stop container, preserve disk).
 *
 * Args:
 *   serviceId: Service ID to freeze
 *
 * Returns: Promise<Object> (freeze confirmation)
 */
async function freezeService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/services/${serviceId}/freeze`, publicKey, secretKey, {});
}

/**
 * Unfreeze a service (restart container).
 *
 * Args:
 *   serviceId: Service ID to unfreeze
 *
 * Returns: Promise<Object> (unfreeze confirmation)
 */
async function unfreezeService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/services/${serviceId}/unfreeze`, publicKey, secretKey, {});
}

/**
 * Lock a service to prevent deletion.
 *
 * Args:
 *   serviceId: Service ID to lock
 *
 * Returns: Promise<Object> (lock confirmation)
 */
async function lockService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/services/${serviceId}/lock`, publicKey, secretKey, {});
}

/**
 * Unlock a service to allow deletion.
 *
 * Args:
 *   serviceId: Service ID to unlock
 *
 * Returns: Promise<Object> (unlock confirmation)
 */
async function unlockService(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('POST', `/services/${serviceId}/unlock`, publicKey, secretKey, {});
}

/**
 * Set unfreeze-on-demand for a service.
 *
 * When enabled, frozen services will automatically wake when HTTP traffic arrives.
 *
 * Args:
 *   serviceId: Service ID to update
 *   enabled: true to enable, false to disable
 *
 * Returns: Promise<Object> (update confirmation)
 */
async function setUnfreezeOnDemand(serviceId, enabled, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, { unfreeze_on_demand: enabled });
}

/**
 * Set show-freeze-page for a service.
 *
 * When enabled, frozen services will show a "frozen" page to visitors instead of an error.
 *
 * Args:
 *   serviceId: Service ID to update
 *   enabled: true to enable, false to disable
 *
 * Returns: Promise<Object> (update confirmation)
 */
async function setShowFreezePage(serviceId, enabled, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, { show_freeze_page: enabled });
}

/**
 * Get service logs.
 *
 * Args:
 *   serviceId: Service ID
 *   all: If true, get all logs; if false, get last ~9000 lines (default: false)
 *
 * Returns: Promise<Object> (log data)
 */
async function getServiceLogs(serviceId, all = false, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const path = all ? `/services/${serviceId}/logs?all=true` : `/services/${serviceId}/logs`;
  return makeRequest('GET', path, publicKey, secretKey);
}

/**
 * Get service environment vault status.
 *
 * Args:
 *   serviceId: Service ID
 *
 * Returns: Promise<Object> (vault status with has_vault, count, updated_at)
 */
async function getServiceEnv(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/services/${serviceId}/env`, publicKey, secretKey);
}

/**
 * Set service environment vault.
 *
 * Args:
 *   serviceId: Service ID
 *   env: Environment content as string (KEY=VALUE format, newline separated)
 *        or object { KEY: "value", KEY2: "value2" }
 *
 * Returns: Promise<Object> (set confirmation)
 */
async function setServiceEnv(serviceId, env, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  // Convert object to KEY=VALUE format if needed
  let envContent = env;
  if (typeof env === 'object' && !Array.isArray(env)) {
    envContent = Object.entries(env)
      .map(([key, value]) => `${key}=${value}`)
      .join('\n');
  }
  // Note: This endpoint uses PUT with text/plain body
  // The makeRequest function sends JSON, so we need to handle this specially
  return makeRequest('PUT', `/services/${serviceId}/env`, publicKey, secretKey, { content: envContent });
}

/**
 * Delete service environment vault.
 *
 * Args:
 *   serviceId: Service ID
 *   keys: Optional array of specific keys to delete (deletes all if not specified)
 *
 * Returns: Promise<Object> (deletion confirmation)
 */
async function deleteServiceEnv(serviceId, keys = null, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = keys ? { keys } : {};
  return makeRequest('DELETE', `/services/${serviceId}/env`, publicKey, secretKey, data);
}

/**
 * Export service environment vault.
 *
 * Args:
 *   serviceId: Service ID
 *
 * Returns: Promise<Object> (exported environment data)
 */
async function exportServiceEnv(serviceId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/services/${serviceId}/env/export`, publicKey, secretKey, {});
}

/**
 * Redeploy a service with new bootstrap script.
 *
 * Args:
 *   serviceId: Service ID to redeploy
 *   bootstrap: Optional new bootstrap script content or URL
 *
 * Returns: Promise<Object> (redeploy confirmation)
 */
async function redeployService(serviceId, bootstrap = null, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {};
  if (bootstrap) {
    if (bootstrap.startsWith('http://') || bootstrap.startsWith('https://')) {
      data.bootstrap = bootstrap;
    } else {
      data.bootstrap_content = bootstrap;
    }
  }
  return makeRequest('POST', `/services/${serviceId}/redeploy`, publicKey, secretKey, data);
}

/**
 * Execute a command in a running service container.
 *
 * Args:
 *   serviceId: Service ID
 *   command: Command to execute
 *   timeout: Optional timeout in milliseconds (default: 30000)
 *
 * Returns: Promise<Object> (execution result with stdout, stderr, exit_code)
 */
async function executeInService(serviceId, command, timeout = 30000, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('POST', `/services/${serviceId}/execute`, publicKey, secretKey, {
    command,
    timeout,
  });

  // If we got a job_id, poll until completion
  const jobId = response.job_id;
  if (jobId) {
    return waitForJob(jobId, publicKey, secretKey);
  }

  return response;
}

/**
 * Resize a service's vCPU allocation.
 *
 * Args:
 *   serviceId: Service ID to resize
 *   vcpu: Number of vCPUs (1-8 typically)
 *
 * Returns: Promise<Object> (updated service info)
 */
async function resizeService(serviceId, vcpu, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('PATCH', `/services/${serviceId}`, publicKey, secretKey, { vcpu });
}

// ============================================================================
// Additional Snapshot Functions
// ============================================================================

/**
 * Lock a snapshot to prevent deletion.
 *
 * Args:
 *   snapshotId: Snapshot ID to lock
 *
 * Returns: Promise<Object> (lock confirmation)
 */
async function lockSnapshot(snapshotId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/snapshots/${snapshotId}/lock`, publicKey, secretKey, {});
}

/**
 * Unlock a snapshot to allow deletion.
 *
 * Args:
 *   snapshotId: Snapshot ID to unlock
 *
 * Returns: Promise<Object> (unlock confirmation)
 */
async function unlockSnapshot(snapshotId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('POST', `/snapshots/${snapshotId}/unlock`, publicKey, secretKey, {});
}

/**
 * Clone a snapshot to create a new session or service.
 *
 * Args:
 *   snapshotId: Snapshot ID to clone
 *   name: Name for the new resource
 *   opts: Optional settings:
 *     - type: "session" or "service" (default: inferred from snapshot)
 *     - shell: Shell for session clones
 *     - ports: Ports array for service clones
 *
 * Returns: Promise<Object> (clone result with new session_id or service_id)
 */
async function cloneSnapshot(snapshotId, name, opts = {}, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const data = {};
  if (name) data.name = name;
  if (opts.type) data.type = opts.type;
  if (opts.shell) data.shell = opts.shell;
  if (opts.ports) data.ports = opts.ports;
  return makeRequest('POST', `/snapshots/${snapshotId}/clone`, publicKey, secretKey, data);
}

// ============================================================================
// Images API (LXD Container Images)
// ============================================================================

/**
 * Publish an LXD container image from a session or service.
 *
 * Args:
 *   sourceType: Source type - "session" or "service"
 *   sourceId: Session ID or Service ID to publish from
 *   options: Optional settings:
 *     - name: Image name/alias
 *     - description: Image description
 *     - publicKey: API public key
 *     - secretKey: API secret key
 *
 * Returns: Promise<Object> (image info with image_id, fingerprint, etc.)
 */
async function imagePublish(sourceType, sourceId, options = {}) {
  const { name, description, publicKey, secretKey } = options;
  const [pk, sk] = resolveCredentials(publicKey, secretKey);

  const data = {
    source_type: sourceType,
    source_id: sourceId,
  };
  if (name) data.name = name;
  if (description) data.description = description;

  return makeRequest('POST', '/images', pk, sk, data);
}

/**
 * List LXD container images.
 *
 * Args:
 *   filterType: Optional filter - "owned", "shared", "public", or null for all accessible
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Array> (list of image objects)
 */
async function listImages(filterType, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const path = filterType ? `/images/${filterType}` : '/images';
  const response = await makeRequest('GET', path, publicKey, secretKey);
  return response.images || [];
}

/**
 * Get details of a specific image.
 *
 * Args:
 *   imageId: Image ID to retrieve
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (image details)
 */
async function getImage(imageId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('GET', `/images/${imageId}`, publicKey, secretKey);
}

/**
 * Delete an image.
 *
 * Args:
 *   imageId: Image ID to delete
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (deletion confirmation)
 */
async function deleteImage(imageId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('DELETE', `/images/${imageId}`, publicKey, secretKey);
}

/**
 * Lock an image to prevent deletion.
 *
 * Args:
 *   imageId: Image ID to lock
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (lock confirmation)
 */
async function lockImage(imageId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/images/${imageId}/lock`, publicKey, secretKey, {});
}

/**
 * Unlock an image to allow deletion.
 *
 * Args:
 *   imageId: Image ID to unlock
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (unlock confirmation)
 */
async function unlockImage(imageId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequestWithSudo('POST', `/images/${imageId}/unlock`, publicKey, secretKey, {});
}

/**
 * Set image visibility (public/private).
 *
 * Args:
 *   imageId: Image ID to update
 *   visibility: "public" or "private"
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (visibility update confirmation)
 */
async function setImageVisibility(imageId, visibility, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/images/${imageId}/visibility`, publicKey, secretKey, { visibility });
}

/**
 * Grant access to an image for another API key (share with trusted user).
 *
 * Args:
 *   imageId: Image ID to share
 *   trustedApiKey: Public key of the user to grant access to
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (grant confirmation)
 */
async function grantImageAccess(imageId, trustedApiKey, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/images/${imageId}/grant`, publicKey, secretKey, { trusted_api_key: trustedApiKey });
}

/**
 * Revoke access to an image for another API key.
 *
 * Args:
 *   imageId: Image ID to revoke access from
 *   trustedApiKey: Public key of the user to revoke access from
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (revoke confirmation)
 */
async function revokeImageAccess(imageId, trustedApiKey, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/images/${imageId}/revoke`, publicKey, secretKey, { trusted_api_key: trustedApiKey });
}

/**
 * List users who have been granted access to an image.
 *
 * Args:
 *   imageId: Image ID to list trusted users for
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Array> (list of trusted API keys)
 */
async function listImageTrusted(imageId, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  const response = await makeRequest('GET', `/images/${imageId}/trusted`, publicKey, secretKey);
  return response.trusted || [];
}

/**
 * Transfer ownership of an image to another API key.
 *
 * Args:
 *   imageId: Image ID to transfer
 *   toApiKey: Public key of the new owner
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (transfer confirmation)
 */
async function transferImage(imageId, toApiKey, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  return makeRequest('POST', `/images/${imageId}/transfer`, publicKey, secretKey, { to_api_key: toApiKey });
}

/**
 * Spawn a new session or service from an image.
 *
 * Args:
 *   imageId: Image ID to spawn from
 *   options: Spawn options:
 *     - type: "session" or "service" (default: "session")
 *     - name: Name for the new resource
 *     - shell: Shell for session (e.g., "bash", "python3")
 *     - ports: Ports array for service
 *     - networkMode: "zerotrust" or "semitrusted"
 *     - vcpu: Number of vCPUs (1-8)
 *     - publicKey: API public key
 *     - secretKey: API secret key
 *
 * Returns: Promise<Object> (spawn result with session_id or service_id)
 */
async function spawnFromImage(imageId, options = {}) {
  const { type, name, shell, ports, networkMode, vcpu, publicKey, secretKey } = options;
  const [pk, sk] = resolveCredentials(publicKey, secretKey);

  const data = {};
  if (type) data.type = type;
  if (name) data.name = name;
  if (shell) data.shell = shell;
  if (ports) data.ports = ports;
  if (networkMode) data.network_mode = networkMode;
  if (vcpu) data.vcpu = vcpu;

  return makeRequest('POST', `/images/${imageId}/spawn`, pk, sk, data);
}

/**
 * Clone an image to create a copy owned by the caller.
 *
 * Args:
 *   imageId: Image ID to clone
 *   options: Clone options:
 *     - name: Name for the cloned image
 *     - description: Description for the cloned image
 *     - publicKey: API public key
 *     - secretKey: API secret key
 *
 * Returns: Promise<Object> (clone result with new image_id)
 */
async function cloneImage(imageId, options = {}) {
  const { name, description, publicKey, secretKey } = options;
  const [pk, sk] = resolveCredentials(publicKey, secretKey);

  const data = {};
  if (name) data.name = name;
  if (description) data.description = description;

  return makeRequest('POST', `/images/${imageId}/clone`, pk, sk, data);
}

// ============================================================================
// Key Validation
// ============================================================================

/**
 * Validate API keys.
 *
 * Returns: Promise<Object> (validation result with valid, tier, expires_at, etc.)
 */
async function validateKeys(publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  // Note: This endpoint is on the portal (unsandbox.com), not the API
  // For SDK purposes, we'll call the API endpoint if available
  return makeRequest('POST', '/keys/validate', publicKey, secretKey, {});
}

// ============================================================================
// Image Generation
// ============================================================================

/**
 * Generate images from text prompt using AI.
 *
 * Args:
 *   prompt: Text description of the image to generate
 *   options: Generation options:
 *     - model: Model to use (optional)
 *     - size: Image size (default: "1024x1024")
 *     - quality: "standard" or "hd" (default: "standard")
 *     - n: Number of images (default: 1)
 *     - publicKey: API public key
 *     - secretKey: API secret key
 *
 * Returns: Promise<Object> (result with images array and created_at)
 */
async function image(prompt, options = {}) {
  const { model, size = "1024x1024", quality = "standard", n = 1, publicKey, secretKey } = options;
  const [pk, sk] = resolveCredentials(publicKey, secretKey);

  const payload = { prompt, size, quality, n };
  if (model) payload.model = model;

  return makeRequest('POST', '/image', pk, sk, payload);
}

// ============================================================================
// PaaS Logs Functions
// ============================================================================

let _lastError = null;

/**
 * Fetch batch logs from the PaaS platform.
 *
 * Args:
 *   source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
 *   lines: Number of lines to fetch (1-10000)
 *   since: Time window - "1m", "5m", "1h", "1d"
 *   grep: Optional filter pattern
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<Object> (log entries)
 */
async function logsFetch(source = 'all', lines = 100, since = '5m', grep = null, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);
  let urlPath = `/logs?source=${source}&lines=${lines}&since=${since}`;
  if (grep) urlPath += `&grep=${encodeURIComponent(grep)}`;
  return makeRequest('GET', urlPath, publicKey, secretKey);
}

/**
 * Stream logs via Server-Sent Events.
 *
 * Args:
 *   source: Log source - "all", "api", "portal", "pool/cammy", "pool/ai"
 *   grep: Optional filter pattern
 *   callback: Function called for each log line (signature: callback(source, line))
 *   publicKey: API public key
 *   secretKey: API secret key
 *
 * Returns: Promise<void> (blocks until interrupted or server closes)
 */
async function logsStream(source = 'all', grep = null, callback = null, publicKey, secretKey) {
  [publicKey, secretKey] = resolveCredentials(publicKey, secretKey);

  let urlPath = `/logs/stream?source=${source}`;
  if (grep) urlPath += `&grep=${encodeURIComponent(grep)}`;

  const timestamp = Math.floor(Date.now() / 1000);
  const signature = await signRequest(secretKey, timestamp, 'GET', urlPath, null);

  const url = `${API_BASE}${urlPath}`;
  const headers = {
    'Authorization': `Bearer ${publicKey}`,
    'X-Timestamp': timestamp.toString(),
    'X-Signature': signature,
    'Accept': 'text/event-stream',
  };

  // Node.js SSE streaming
  if (IS_NODE) {
    const https = await import('https');
    return new Promise((resolve, reject) => {
      const urlObj = new URL(url);
      const options = {
        hostname: urlObj.hostname,
        path: urlObj.pathname + urlObj.search,
        method: 'GET',
        headers,
      };

      const req = https.default.request(options, (res) => {
        res.on('data', (chunk) => {
          const lines = chunk.toString().split('\n');
          for (const line of lines) {
            if (line.startsWith('data: ')) {
              const data = line.substring(6);
              try {
                const entry = JSON.parse(data);
                if (callback) {
                  callback(entry.source || source, entry.line || data);
                } else {
                  console.log(`[${entry.source || source}] ${entry.line || data}`);
                }
              } catch (e) {
                if (callback) {
                  callback(source, data);
                } else {
                  console.log(`[${source}] ${data}`);
                }
              }
            }
          }
        });
        res.on('end', resolve);
        res.on('error', reject);
      });

      req.on('error', reject);
      req.end();
    });
  }

  // Browser EventSource not directly supported with custom headers
  throw new Error('logsStream is only supported in Node.js');
}

// ============================================================================
// Utility Functions
// ============================================================================

const SDK_VERSION = '4.2.0';

/**
 * Get the SDK version string.
 *
 * Returns: string (e.g., "4.2.0")
 */
function sdkVersion() {
  return SDK_VERSION;
}

/**
 * Check if the API is healthy and responding.
 *
 * Returns: Promise<boolean>
 */
async function healthCheck() {
  try {
    if (IS_NODE) {
      const https = await import('https');
      return new Promise((resolve) => {
        const req = https.default.get(`${API_BASE}/health`, (res) => {
          resolve(res.statusCode === 200);
        });
        req.on('error', () => {
          _lastError = 'Health check failed: network error';
          resolve(false);
        });
        req.setTimeout(10000, () => {
          _lastError = 'Health check failed: timeout';
          resolve(false);
        });
      });
    } else {
      const response = await fetch(`${API_BASE}/health`);
      return response.status === 200;
    }
  } catch (e) {
    _lastError = `Health check failed: ${e.message}`;
    return false;
  }
}

/**
 * Get the last error message.
 *
 * Returns: string|null
 */
function lastError() {
  return _lastError;
}

/**
 * Sign a message using HMAC-SHA256.
 *
 * This is the underlying signing function used for request authentication.
 * Exposed for testing and debugging purposes.
 *
 * Args:
 *   secretKey: The secret key for signing
 *   message: The message to sign
 *
 * Returns: Promise<string> (64-character lowercase hex string)
 */
async function hmacSign(secretKey, message) {
  if (IS_NODE) {
    return crypto.createHmac('sha256', secretKey).update(message).digest('hex');
  } else {
    // Browser Web Crypto API
    const encoder = new TextEncoder();
    const keyData = encoder.encode(secretKey);
    const msgData = encoder.encode(message);
    const cryptoKey = await window.crypto.subtle.importKey(
      'raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
    );
    const signature = await window.crypto.subtle.sign('HMAC', cryptoKey, msgData);
    return Array.from(new Uint8Array(signature))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }
}

// ES Module exports
export {
  // Code execution (8)
  executeCode,
  executeAsync,
  getJob,
  waitForJob,
  cancelJob,
  listJobs,
  getLanguages,
  detectLanguage,
  // Session management (9)
  listSessions,
  getSession,
  createSession,
  deleteSession,
  freezeSession,
  unfreezeSession,
  boostSession,
  unboostSession,
  shellSession,
  // Service management (17)
  listServices,
  createService,
  getService,
  updateService,
  deleteService,
  freezeService,
  unfreezeService,
  lockService,
  unlockService,
  setUnfreezeOnDemand,
  setShowFreezePage,
  getServiceLogs,
  getServiceEnv,
  setServiceEnv,
  deleteServiceEnv,
  exportServiceEnv,
  redeployService,
  executeInService,
  resizeService,
  // Snapshot management (9)
  sessionSnapshot,
  serviceSnapshot,
  listSnapshots,
  getSnapshot,
  restoreSnapshot,
  deleteSnapshot,
  lockSnapshot,
  unlockSnapshot,
  cloneSnapshot,
  // Images API (13)
  imagePublish,
  listImages,
  getImage,
  deleteImage,
  lockImage,
  unlockImage,
  setImageVisibility,
  grantImageAccess,
  revokeImageAccess,
  listImageTrusted,
  transferImage,
  spawnFromImage,
  cloneImage,
  // PaaS Logs (2)
  logsFetch,
  logsStream,
  // Key validation
  validateKeys,
  // Utilities
  sdkVersion,
  healthCheck,
  lastError,
  hmacSign,
  // Image generation (AI)
  image,
  // Errors
  CredentialsError,
  // CLI
  cliMain,
  // Vault (browser only)
  UnsandboxVault,
};

// Default export for convenience
export default {
  // Code execution (8)
  executeCode,
  executeAsync,
  getJob,
  waitForJob,
  cancelJob,
  listJobs,
  getLanguages,
  detectLanguage,
  // Session management (9)
  listSessions,
  getSession,
  createSession,
  deleteSession,
  freezeSession,
  unfreezeSession,
  boostSession,
  unboostSession,
  shellSession,
  // Service management (17)
  listServices,
  createService,
  getService,
  updateService,
  deleteService,
  freezeService,
  unfreezeService,
  lockService,
  unlockService,
  setUnfreezeOnDemand,
  setShowFreezePage,
  getServiceLogs,
  getServiceEnv,
  setServiceEnv,
  deleteServiceEnv,
  exportServiceEnv,
  redeployService,
  executeInService,
  resizeService,
  // Snapshot management (9)
  sessionSnapshot,
  serviceSnapshot,
  listSnapshots,
  getSnapshot,
  restoreSnapshot,
  deleteSnapshot,
  lockSnapshot,
  unlockSnapshot,
  cloneSnapshot,
  // Images API (13)
  imagePublish,
  listImages,
  getImage,
  deleteImage,
  lockImage,
  unlockImage,
  setImageVisibility,
  grantImageAccess,
  revokeImageAccess,
  listImageTrusted,
  transferImage,
  spawnFromImage,
  cloneImage,
  // PaaS Logs (2)
  logsFetch,
  logsStream,
  // Key validation
  validateKeys,
  // Utilities
  sdkVersion,
  healthCheck,
  lastError,
  hmacSign,
  // Image generation (AI)
  image,
  // Errors
  CredentialsError,
  // CLI
  cliMain,
  // Vault (browser only)
  UnsandboxVault,
};

// ============================================================================
// CLI Implementation
// ============================================================================

const HELP_TEXT = `
unsandbox CLI - Secure code execution platform

USAGE:
  node un.js [options] <source_file>     Execute code file
  node un.js -s <lang> '<code>'          Execute inline code
  node un.js session [options]           Interactive session management
  node un.js service [options]           Service management
  node un.js snapshot [options]          Snapshot management
  node un.js key                         Check API key validity
  node un.js languages [--json]          List available languages

GLOBAL OPTIONS:
  -s, --shell <lang>      Language for inline code execution
  -e, --env <KEY=VAL>     Set environment variable (can be repeated)
  -f, --file <path>       Add input file to /tmp/ (can be repeated)
  -F, --file-path <path>  Add input file with path preserved
  -a, --artifacts         Return compiled artifacts
  -o, --output <dir>      Output directory for artifacts
  -p, --public-key <key>  API public key
  -k, --secret-key <key>  API secret key
  -n, --network <mode>    Network mode: zerotrust (default) or semitrusted
  -v, --vcpu <n>          vCPU count (1-8)
  -y, --yes               Skip confirmation prompts
  -h, --help              Show this help message

SESSION COMMANDS:
  node un.js session                     Start interactive bash session
  node un.js session --shell python3     Start Python REPL
  node un.js session --tmux              Persistent session with tmux
  node un.js session --screen            Persistent session with screen
  node un.js session --list              List active sessions
  node un.js session --attach <id>       Reconnect to session
  node un.js session --kill <id>         Terminate session
  node un.js session --freeze <id>       Pause session
  node un.js session --unfreeze <id>     Resume session
  node un.js session --boost <id>        Add resources
  node un.js session --unboost <id>      Remove boost
  node un.js session --snapshot <id>     Create snapshot

SERVICE COMMANDS:
  node un.js service --list              List all services
  node un.js service --name <n> --ports <p> --bootstrap <cmd>
                                         Create new service
  node un.js service --info <id>         Get service details
  node un.js service --logs <id>         Get service logs
  node un.js service --tail <id>         Get last 9000 lines
  node un.js service --freeze <id>       Pause service
  node un.js service --unfreeze <id>     Resume service
  node un.js service --destroy <id>      Delete service
  node un.js service --lock <id>         Prevent deletion
  node un.js service --unlock <id>       Allow deletion
  node un.js service --execute <id> <cmd> Run command in service
  node un.js service --redeploy <id>     Re-run bootstrap
  node un.js service --snapshot <id>     Create snapshot

SERVICE ENV COMMANDS:
  node un.js service env status <id>     Show vault status
  node un.js service env set <id>        Set from --env-file or stdin
  node un.js service env export <id>     Export to stdout
  node un.js service env delete <id>     Delete vault

SNAPSHOT COMMANDS:
  node un.js snapshot --list             List all snapshots
  node un.js snapshot --info <id>        Get snapshot details
  node un.js snapshot --delete <id>      Delete snapshot
  node un.js snapshot --lock <id>        Prevent deletion
  node un.js snapshot --unlock <id>      Allow deletion
  node un.js snapshot --clone <id>       Clone snapshot to new resource

EXAMPLES:
  node un.js script.py                   Execute Python script
  node un.js -s bash 'echo hello'        Run bash command
  node un.js -e DEBUG=1 script.py        Execute with env var
  node un.js -n semitrusted crawler.py   Execute with network access
  node un.js session --tmux              Start persistent session
  node un.js service --list              List all services
`;

/**
 * Parse command line arguments manually.
 * Returns object with parsed options and positional args.
 */
function parseArgs(args) {
  const result = {
    command: null,        // session, service, snapshot, key, or null (execute)
    subcommand: null,     // env (for service env commands)
    positional: [],
    shell: null,
    env: [],
    files: [],
    filesWithPath: [],
    artifacts: false,
    output: null,
    publicKey: null,
    secretKey: null,
    network: 'zerotrust',
    vcpu: 1,
    yes: false,
    help: false,
    // Session options
    list: false,
    attach: null,
    kill: null,
    freeze: null,
    unfreeze: null,
    boost: null,
    unboost: null,
    snapshot: null,
    snapshotName: null,
    hot: false,
    audit: false,
    tmux: false,
    screen: false,
    // Service options
    name: null,
    ports: null,
    domains: null,
    type: null,
    bootstrap: null,
    bootstrapFile: null,
    envFile: null,
    info: null,
    logs: null,
    tail: null,
    destroy: null,
    lock: null,
    unlock: null,
    resize: null,
    redeploy: null,
    execute: null,
    executeCmd: null,
    // Snapshot options
    delete: null,
    clone: null,
    // Image options
    publish: null,
    sourceType: null,
    visibility: null,
    visibilityMode: null,
    spawn: null,
    // Languages options
    json: false,
  };

  let i = 0;
  while (i < args.length) {
    const arg = args[i];

    // Check for subcommands first
    if (arg === 'session' && result.command === null) {
      result.command = 'session';
      i++;
      continue;
    }
    if (arg === 'service' && result.command === null) {
      result.command = 'service';
      i++;
      continue;
    }
    if (arg === 'snapshot' && result.command === null) {
      result.command = null;
      // Check for snapshot subcommand
      result.command = 'snapshot';
      i++;
      continue;
    }
    if (arg === 'key' && result.command === null) {
      result.command = 'key';
      i++;
      continue;
    }
    if (arg === 'languages' && result.command === null) {
      result.command = 'languages';
      i++;
      continue;
    }
    if (arg === 'image' && result.command === null) {
      result.command = 'image';
      i++;
      continue;
    }
    // Service env subcommand
    if (arg === 'env' && result.command === 'service') {
      result.subcommand = 'env';
      i++;
      continue;
    }
    // Service env operations (status, set, export, delete)
    if (result.command === 'service' && result.subcommand === 'env') {
      if (['status', 'set', 'export', 'delete'].includes(arg)) {
        result.envOperation = arg;
        i++;
        continue;
      }
    }

    // Parse options
    if (arg === '-h' || arg === '--help') {
      result.help = true;
      i++;
    } else if (arg === '-s' || arg === '--shell') {
      result.shell = args[++i];
      i++;
    } else if (arg === '-e' || arg === '--env') {
      result.env.push(args[++i]);
      i++;
    } else if (arg === '-f' || arg === '--file') {
      result.files.push(args[++i]);
      i++;
    } else if (arg === '-F' || arg === '--file-path') {
      result.filesWithPath.push(args[++i]);
      i++;
    } else if (arg === '-a' || arg === '--artifacts') {
      result.artifacts = true;
      i++;
    } else if (arg === '-o' || arg === '--output') {
      result.output = args[++i];
      i++;
    } else if (arg === '-p' || arg === '--public-key') {
      result.publicKey = args[++i];
      i++;
    } else if (arg === '-k' || arg === '--secret-key') {
      result.secretKey = args[++i];
      i++;
    } else if (arg === '-n' || arg === '--network') {
      result.network = args[++i];
      i++;
    } else if (arg === '-v' || arg === '--vcpu') {
      result.vcpu = parseInt(args[++i], 10);
      i++;
    } else if (arg === '-y' || arg === '--yes') {
      result.yes = true;
      i++;
    } else if (arg === '--json') {
      result.json = true;
      i++;
    } else if (arg === '-l' || arg === '--list') {
      result.list = true;
      i++;
    } else if (arg === '--attach') {
      result.attach = args[++i];
      i++;
    } else if (arg === '--kill') {
      result.kill = args[++i];
      i++;
    } else if (arg === '--freeze') {
      result.freeze = args[++i];
      i++;
    } else if (arg === '--unfreeze') {
      result.unfreeze = args[++i];
      i++;
    } else if (arg === '--boost') {
      result.boost = args[++i];
      i++;
    } else if (arg === '--unboost') {
      result.unboost = args[++i];
      i++;
    } else if (arg === '--snapshot') {
      result.snapshot = args[++i];
      i++;
    } else if (arg === '--snapshot-name') {
      result.snapshotName = args[++i];
      i++;
    } else if (arg === '--hot') {
      result.hot = true;
      i++;
    } else if (arg === '--audit') {
      result.audit = true;
      i++;
    } else if (arg === '--tmux') {
      result.tmux = true;
      i++;
    } else if (arg === '--screen') {
      result.screen = true;
      i++;
    } else if (arg === '--name') {
      result.name = args[++i];
      i++;
    } else if (arg === '--ports') {
      result.ports = args[++i];
      i++;
    } else if (arg === '--domains') {
      result.domains = args[++i];
      i++;
    } else if (arg === '--type') {
      result.type = args[++i];
      i++;
    } else if (arg === '--bootstrap') {
      result.bootstrap = args[++i];
      i++;
    } else if (arg === '--bootstrap-file') {
      result.bootstrapFile = args[++i];
      i++;
    } else if (arg === '--env-file') {
      result.envFile = args[++i];
      i++;
    } else if (arg === '--info') {
      result.info = args[++i];
      i++;
    } else if (arg === '--logs') {
      result.logs = args[++i];
      i++;
    } else if (arg === '--tail') {
      result.tail = args[++i];
      i++;
    } else if (arg === '--destroy') {
      result.destroy = args[++i];
      i++;
    } else if (arg === '--lock') {
      result.lock = args[++i];
      i++;
    } else if (arg === '--unlock') {
      result.unlock = args[++i];
      i++;
    } else if (arg === '--resize') {
      result.resize = args[++i];
      i++;
    } else if (arg === '--redeploy') {
      result.redeploy = args[++i];
      i++;
    } else if (arg === '--execute') {
      result.execute = args[++i];
      // Next arg is the command to execute
      if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
        result.executeCmd = args[++i];
      }
      i++;
    } else if (arg === '--delete') {
      result.delete = args[++i];
      i++;
    } else if (arg === '--clone') {
      result.clone = args[++i];
      i++;
    } else if (arg === '--publish') {
      result.publish = args[++i];
      i++;
    } else if (arg === '--source-type') {
      result.sourceType = args[++i];
      i++;
    } else if (arg === '--visibility') {
      result.visibility = args[++i];
      // Next arg might be the mode
      if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
        result.visibilityMode = args[++i];
      }
      i++;
    } else if (arg === '--spawn') {
      result.spawn = args[++i];
      i++;
    } else if (arg.startsWith('-')) {
      console.error(`Error: Unknown option: ${arg}`);
      process.exit(2);
    } else {
      result.positional.push(arg);
      i++;
    }
  }

  return result;
}

/**
 * Format timestamp for display.
 */
function formatTimestamp(ts) {
  if (!ts) return 'N/A';
  const date = new Date(ts * 1000);
  return date.toISOString().replace('T', ' ').substring(0, 19);
}

/**
 * Format list output in table format.
 */
function formatTable(items, columns) {
  if (!items || items.length === 0) {
    console.log('No items found.');
    return;
  }

  // Calculate column widths
  const widths = {};
  for (const col of columns) {
    widths[col.key] = col.label.length;
    for (const item of items) {
      const val = String(col.getter ? col.getter(item) : (item[col.key] || 'N/A'));
      widths[col.key] = Math.max(widths[col.key], val.length);
    }
  }

  // Print header
  let header = '';
  for (const col of columns) {
    header += col.label.padEnd(widths[col.key] + 2);
  }
  console.log(header);

  // Print rows
  for (const item of items) {
    let row = '';
    for (const col of columns) {
      const val = String(col.getter ? col.getter(item) : (item[col.key] || 'N/A'));
      row += val.padEnd(widths[col.key] + 2);
    }
    console.log(row);
  }
}

/**
 * Handle session commands.
 */
async function handleSession(opts) {
  const pk = opts.publicKey;
  const sk = opts.secretKey;

  // List sessions
  if (opts.list) {
    const sessions = await listSessions(pk, sk);
    formatTable(sessions, [
      { key: 'session_id', label: 'ID' },
      { key: 'name', label: 'NAME' },
      { key: 'status', label: 'STATUS' },
      { key: 'created_at', label: 'CREATED', getter: (s) => formatTimestamp(s.created_at) },
    ]);
    return;
  }

  // Attach to session
  if (opts.attach) {
    const session = await getSession(opts.attach, pk, sk);
    console.log(`Session ${opts.attach}:`);
    console.log(JSON.stringify(session, null, 2));
    console.log('\nNote: Interactive attach requires WebSocket connection (not supported in this CLI)');
    return;
  }

  // Kill session
  if (opts.kill) {
    await deleteSession(opts.kill, pk, sk);
    console.log(`Session ${opts.kill} terminated.`);
    return;
  }

  // Freeze session
  if (opts.freeze) {
    await freezeSession(opts.freeze, pk, sk);
    console.log(`Session ${opts.freeze} frozen.`);
    return;
  }

  // Unfreeze session
  if (opts.unfreeze) {
    await unfreezeSession(opts.unfreeze, pk, sk);
    console.log(`Session ${opts.unfreeze} unfrozen.`);
    return;
  }

  // Boost session
  if (opts.boost) {
    await boostSession(opts.boost, opts.vcpu || 2, pk, sk);
    console.log(`Session ${opts.boost} boosted.`);
    return;
  }

  // Unboost session
  if (opts.unboost) {
    await unboostSession(opts.unboost, pk, sk);
    console.log(`Session ${opts.unboost} unboosted.`);
    return;
  }

  // Snapshot session
  if (opts.snapshot) {
    const snapshotId = await sessionSnapshot(opts.snapshot, pk, sk, opts.snapshotName, opts.hot);
    console.log(`Snapshot created: ${snapshotId}`);
    return;
  }

  // Create new session
  const sessionOpts = {
    networkMode: opts.network,
    vcpu: opts.vcpu,
  };
  if (opts.tmux) sessionOpts.multiplexer = 'tmux';
  if (opts.screen) sessionOpts.multiplexer = 'screen';

  const session = await createSession(opts.shell || 'bash', sessionOpts, pk, sk);
  console.log(`Session created: ${session.session_id}`);
  console.log(JSON.stringify(session, null, 2));
  console.log('\nNote: Interactive session requires WebSocket connection (not supported in this CLI)');
}

/**
 * Handle service commands.
 */
async function handleService(opts) {
  const pk = opts.publicKey;
  const sk = opts.secretKey;

  // Handle env subcommand
  if (opts.subcommand === 'env') {
    const serviceId = opts.positional[0];
    if (!serviceId) {
      console.error('Error: Service ID required for env commands');
      process.exit(2);
    }

    switch (opts.envOperation) {
      case 'status': {
        const status = await getServiceEnv(serviceId, pk, sk);
        console.log(JSON.stringify(status, null, 2));
        break;
      }
      case 'set': {
        let envContent;
        if (opts.envFile) {
          envContent = fs.readFileSync(opts.envFile, 'utf-8');
        } else {
          // Read from stdin
          envContent = fs.readFileSync(0, 'utf-8');
        }
        await setServiceEnv(serviceId, envContent, pk, sk);
        console.log('Environment vault updated.');
        break;
      }
      case 'export': {
        const exported = await exportServiceEnv(serviceId, pk, sk);
        if (exported.content) {
          console.log(exported.content);
        } else {
          console.log(JSON.stringify(exported, null, 2));
        }
        break;
      }
      case 'delete': {
        await deleteServiceEnv(serviceId, null, pk, sk);
        console.log('Environment vault deleted.');
        break;
      }
      default:
        console.error('Error: Unknown env operation. Use: status, set, export, delete');
        process.exit(2);
    }
    return;
  }

  // List services
  if (opts.list) {
    const services = await listServices(pk, sk);
    formatTable(services, [
      { key: 'service_id', label: 'ID' },
      { key: 'name', label: 'NAME' },
      { key: 'status', label: 'STATUS' },
      { key: 'created_at', label: 'CREATED', getter: (s) => formatTimestamp(s.created_at) },
    ]);
    return;
  }

  // Get service info
  if (opts.info) {
    const service = await getService(opts.info, pk, sk);
    console.log(JSON.stringify(service, null, 2));
    return;
  }

  // Get service logs
  if (opts.logs) {
    const logs = await getServiceLogs(opts.logs, true, pk, sk);
    if (logs.logs) {
      console.log(logs.logs);
    } else if (logs.stdout) {
      console.log(logs.stdout);
    } else {
      console.log(JSON.stringify(logs, null, 2));
    }
    return;
  }

  // Get service tail
  if (opts.tail) {
    const logs = await getServiceLogs(opts.tail, false, pk, sk);
    if (logs.logs) {
      console.log(logs.logs);
    } else if (logs.stdout) {
      console.log(logs.stdout);
    } else {
      console.log(JSON.stringify(logs, null, 2));
    }
    return;
  }

  // Freeze service
  if (opts.freeze) {
    await freezeService(opts.freeze, pk, sk);
    console.log(`Service ${opts.freeze} frozen.`);
    return;
  }

  // Unfreeze service
  if (opts.unfreeze) {
    await unfreezeService(opts.unfreeze, pk, sk);
    console.log(`Service ${opts.unfreeze} unfrozen.`);
    return;
  }

  // Destroy service
  if (opts.destroy) {
    await deleteService(opts.destroy, pk, sk);
    console.log(`Service ${opts.destroy} destroyed.`);
    return;
  }

  // Lock service
  if (opts.lock) {
    await lockService(opts.lock, pk, sk);
    console.log(`Service ${opts.lock} locked.`);
    return;
  }

  // Unlock service
  if (opts.unlock) {
    await unlockService(opts.unlock, pk, sk);
    console.log(`Service ${opts.unlock} unlocked.`);
    return;
  }

  // Resize service
  if (opts.resize) {
    await updateService(opts.resize, { vcpu: opts.vcpu }, pk, sk);
    console.log(`Service ${opts.resize} resized.`);
    return;
  }

  // Redeploy service
  if (opts.redeploy) {
    let bootstrap = opts.bootstrap;
    if (opts.bootstrapFile) {
      bootstrap = fs.readFileSync(opts.bootstrapFile, 'utf-8');
    }
    await redeployService(opts.redeploy, bootstrap, pk, sk);
    console.log(`Service ${opts.redeploy} redeployed.`);
    return;
  }

  // Execute command in service
  if (opts.execute) {
    if (!opts.executeCmd) {
      console.error('Error: Command required for --execute');
      process.exit(2);
    }
    const result = await executeInService(opts.execute, opts.executeCmd, 30000, pk, sk);
    if (result.stdout) {
      process.stdout.write(result.stdout);
    }
    if (result.stderr) {
      process.stderr.write(result.stderr);
    }
    if (result.exit_code !== undefined) {
      console.log('---');
      console.log(`Exit code: ${result.exit_code}`);
    }
    return;
  }

  // Snapshot service
  if (opts.snapshot) {
    const snapshotId = await serviceSnapshot(opts.snapshot, pk, sk, opts.snapshotName, opts.hot);
    console.log(`Snapshot created: ${snapshotId}`);
    return;
  }

  // Create new service
  if (opts.name) {
    let ports = [];
    if (opts.ports) {
      ports = opts.ports.split(',').map((p) => parseInt(p.trim(), 10));
    }

    let bootstrap = opts.bootstrap;
    if (opts.bootstrapFile) {
      bootstrap = fs.readFileSync(opts.bootstrapFile, 'utf-8');
    }

    const serviceOpts = {
      networkMode: opts.network,
      vcpu: opts.vcpu,
    };
    if (opts.domains) {
      serviceOpts.domains = opts.domains.split(',').map((d) => d.trim());
    }
    if (opts.type) {
      serviceOpts.serviceType = opts.type;
    }

    const service = await createService(opts.name, ports, bootstrap, serviceOpts, pk, sk);
    console.log(`Service created: ${service.service_id}`);
    console.log(JSON.stringify(service, null, 2));
    return;
  }

  // No action specified
  console.error('Error: No service action specified. Use --list, --name, --info, etc.');
  process.exit(2);
}

/**
 * Handle snapshot commands.
 */
async function handleSnapshot(opts) {
  const pk = opts.publicKey;
  const sk = opts.secretKey;

  // List snapshots
  if (opts.list) {
    const snapshots = await listSnapshots(pk, sk);
    formatTable(snapshots, [
      { key: 'snapshot_id', label: 'ID' },
      { key: 'name', label: 'NAME' },
      { key: 'type', label: 'TYPE' },
      { key: 'created_at', label: 'CREATED', getter: (s) => formatTimestamp(s.created_at) },
    ]);
    return;
  }

  // Get snapshot info
  if (opts.info) {
    // Use GET /snapshots/{id} - need to add this function or use makeRequest directly
    const [resolvedPk, resolvedSk] = resolveCredentials(pk, sk);
    const snapshot = await makeRequest('GET', `/snapshots/${opts.info}`, resolvedPk, resolvedSk);
    console.log(JSON.stringify(snapshot, null, 2));
    return;
  }

  // Delete snapshot
  if (opts.delete) {
    await deleteSnapshot(opts.delete, pk, sk);
    console.log(`Snapshot ${opts.delete} deleted.`);
    return;
  }

  // Lock snapshot
  if (opts.lock) {
    await lockSnapshot(opts.lock, pk, sk);
    console.log(`Snapshot ${opts.lock} locked.`);
    return;
  }

  // Unlock snapshot
  if (opts.unlock) {
    await unlockSnapshot(opts.unlock, pk, sk);
    console.log(`Snapshot ${opts.unlock} unlocked.`);
    return;
  }

  // Clone snapshot
  if (opts.clone) {
    const cloneOpts = {};
    if (opts.type) cloneOpts.type = opts.type;
    if (opts.shell) cloneOpts.shell = opts.shell;
    if (opts.ports) {
      cloneOpts.ports = opts.ports.split(',').map((p) => parseInt(p.trim(), 10));
    }

    const result = await cloneSnapshot(opts.clone, opts.name, cloneOpts, pk, sk);
    console.log('Snapshot cloned:');
    console.log(JSON.stringify(result, null, 2));
    return;
  }

  // No action specified
  console.error('Error: No snapshot action specified. Use --list, --info, --delete, --clone, etc.');
  process.exit(2);
}

/**
 * Handle image command.
 */
async function handleImage(opts) {
  const pk = opts.publicKey;
  const sk = opts.secretKey;

  // List images
  if (opts.list) {
    const images = await listImages(null, pk, sk);
    formatTable(images, [
      { key: 'image_id', label: 'ID' },
      { key: 'name', label: 'NAME' },
      { key: 'visibility', label: 'VISIBILITY' },
      { key: 'source_type', label: 'SOURCE' },
      { key: 'created_at', label: 'CREATED', getter: (s) => formatTimestamp(s.created_at) },
    ]);
    return;
  }

  // Get image info
  if (opts.info) {
    const image = await getImage(opts.info, pk, sk);
    console.log(JSON.stringify(image, null, 2));
    return;
  }

  // Delete image
  if (opts.delete) {
    await deleteImage(opts.delete, pk, sk);
    console.log(`Image ${opts.delete} deleted.`);
    return;
  }

  // Lock image
  if (opts.lock) {
    await lockImage(opts.lock, pk, sk);
    console.log(`Image ${opts.lock} locked.`);
    return;
  }

  // Unlock image
  if (opts.unlock) {
    await unlockImage(opts.unlock, pk, sk);
    console.log(`Image ${opts.unlock} unlocked.`);
    return;
  }

  // Publish image
  if (opts.publish) {
    if (!opts.sourceType) {
      console.error('Error: --source-type required for --publish');
      process.exit(2);
    }
    const pubOpts = { publicKey: pk, secretKey: sk };
    if (opts.name) pubOpts.name = opts.name;
    const result = await imagePublish(opts.sourceType, opts.publish, pubOpts);
    const imageId = result.image_id || result.id;
    console.log(`Image published: ${imageId}`);
    return;
  }

  // Set visibility
  if (opts.visibility && opts.visibilityMode) {
    if (!['private', 'unlisted', 'public'].includes(opts.visibilityMode)) {
      console.error('Error: visibility must be private, unlisted, or public');
      process.exit(2);
    }
    await setImageVisibility(opts.visibility, opts.visibilityMode, pk, sk);
    console.log(`Image ${opts.visibility} visibility set to ${opts.visibilityMode}.`);
    return;
  }

  // Spawn from image
  if (opts.spawn) {
    if (!opts.name) {
      console.error('Error: --name required for --spawn');
      process.exit(2);
    }
    const spawnOpts = { publicKey: pk, secretKey: sk, name: opts.name };
    if (opts.ports) {
      spawnOpts.ports = opts.ports.split(',').map((p) => parseInt(p.trim(), 10));
    }
    const result = await spawnFromImage(opts.spawn, spawnOpts);
    const serviceId = result.service_id || result.id;
    console.log(`Service spawned: ${serviceId}`);
    return;
  }

  // Clone image
  if (opts.clone) {
    const cloneOpts = { publicKey: pk, secretKey: sk };
    if (opts.name) cloneOpts.name = opts.name;
    const result = await cloneImage(opts.clone, cloneOpts);
    const imageId = result.image_id || result.id;
    console.log(`Image cloned: ${imageId}`);
    return;
  }

  // No action specified
  console.error('Error: No image action specified. Use --list, --info, --delete, --publish, --spawn, --clone, etc.');
  process.exit(2);
}

/**
 * Handle key command.
 */
async function handleKey(opts) {
  try {
    const result = await validateKeys(opts.publicKey, opts.secretKey);
    console.log('API Key Status:');
    console.log(JSON.stringify(result, null, 2));
  } catch (err) {
    // If validate endpoint doesn't exist, just show that credentials were resolved
    const [pk] = resolveCredentials(opts.publicKey, opts.secretKey);
    console.log(`Public Key: ${pk}`);
    console.log('Key validation endpoint returned error - key may still be valid.');
  }
}

/**
 * Handle languages command.
 */
async function handleLanguages(opts) {
  const languages = await getLanguages(opts.publicKey, opts.secretKey);

  if (opts.json) {
    // Output as JSON array
    console.log(JSON.stringify(languages));
  } else {
    // Output one language per line (pipe-friendly)
    for (const lang of languages) {
      console.log(lang);
    }
  }
}

/**
 * Handle execute command (default).
 */
async function handleExecute(opts) {
  const pk = opts.publicKey;
  const sk = opts.secretKey;

  let code;
  let language;

  // Inline code with -s flag
  if (opts.shell && opts.positional.length > 0) {
    language = opts.shell;
    code = opts.positional[0];
  }
  // File execution
  else if (opts.positional.length > 0) {
    const filePath = opts.positional[0];
    language = detectLanguage(filePath);
    if (!language) {
      console.error(`Error: Could not detect language for file: ${filePath}`);
      console.error('Use -s <language> to specify explicitly.');
      process.exit(2);
    }
    code = fs.readFileSync(filePath, 'utf-8');
  } else {
    console.error('Error: No source file or inline code provided.');
    console.error('Use: node un.js <file> or node un.js -s <lang> "<code>"');
    process.exit(2);
  }

  // Build execution options
  const execPayload = {
    language,
    code,
  };

  // Add environment variables
  if (opts.env.length > 0) {
    execPayload.env = {};
    for (const e of opts.env) {
      const idx = e.indexOf('=');
      if (idx > 0) {
        execPayload.env[e.substring(0, idx)] = e.substring(idx + 1);
      }
    }
  }

  // Add network mode if not default
  if (opts.network !== 'zerotrust') {
    execPayload.network_mode = opts.network;
  }

  // Execute code
  const [resolvedPk, resolvedSk] = resolveCredentials(pk, sk);
  const result = await makeRequest('POST', '/execute', resolvedPk, resolvedSk, execPayload);

  // If job_id returned, wait for completion
  let finalResult = result;
  if (result.job_id && ['pending', 'running'].includes(result.status)) {
    finalResult = await waitForJob(result.job_id, resolvedPk, resolvedSk);
  }

  // Output results
  if (finalResult.stdout) {
    process.stdout.write(finalResult.stdout);
  }
  if (finalResult.stderr) {
    process.stderr.write(finalResult.stderr);
  }

  // Print summary
  console.log('---');
  if (finalResult.exit_code !== undefined) {
    console.log(`Exit code: ${finalResult.exit_code}`);
  }
  if (finalResult.execution_time_ms !== undefined) {
    console.log(`Execution time: ${finalResult.execution_time_ms}ms`);
  } else if (finalResult.duration_ms !== undefined) {
    console.log(`Execution time: ${finalResult.duration_ms}ms`);
  }

  // Exit with code's exit code
  if (finalResult.exit_code && finalResult.exit_code !== 0) {
    process.exit(1);
  }
}

/**
 * Main CLI entry point.
 */
async function cliMain() {
  const args = process.argv.slice(2);

  if (args.length === 0) {
    console.log(HELP_TEXT);
    process.exit(0);
  }

  const opts = parseArgs(args);

  if (opts.help) {
    console.log(HELP_TEXT);
    process.exit(0);
  }

  try {
    switch (opts.command) {
      case 'session':
        await handleSession(opts);
        break;
      case 'service':
        await handleService(opts);
        break;
      case 'snapshot':
        await handleSnapshot(opts);
        break;
      case 'image':
        await handleImage(opts);
        break;
      case 'key':
        await handleKey(opts);
        break;
      case 'languages':
        await handleLanguages(opts);
        break;
      default:
        await handleExecute(opts);
    }
  } catch (err) {
    if (err instanceof CredentialsError) {
      console.error(`Error: ${err.message}`);
      process.exit(3);
    } else if (err.message && err.message.includes('HTTP 401')) {
      console.error('Error: Authentication failed. Check your API keys.');
      process.exit(3);
    } else if (err.message && err.message.includes('HTTP 403')) {
      console.error('Error: Access denied.');
      process.exit(3);
    } else if (err.message && err.message.includes('HTTP 404')) {
      console.error('Error: Resource not found.');
      process.exit(4);
    } else if (err.message && err.message.includes('timeout')) {
      console.error('Error: Request timeout.');
      process.exit(5);
    } else {
      console.error(`Error: ${err.message}`);
      process.exit(1);
    }
  }
}

// CLI entry point - detect if running as main module (ESM) [Node.js only]
// In ESM, we use import.meta.url to check if this is the main module
if (IS_NODE) {
  const isMain = process.argv[1] && (
    process.argv[1].endsWith('/un.js') ||
    process.argv[1].endsWith('\\un.js') ||
    import.meta.url === `file://${process.argv[1]}`
  );

  if (isMain) {
    cliMain().catch((err) => {
      console.error('Error:', err.message);
      process.exit(1);
    });
  }
}

Documentation clarifications

Dependencies

C Binary (un1) — requires libcurl and libwebsockets:

sudo apt install build-essential libcurl4-openssl-dev libwebsockets-dev
wget unsandbox.com/downloads/un.c && gcc -O2 -o un un.c -lcurl -lwebsockets

SDK Implementations — most use stdlib only (Ruby, JS, Go, etc). Some require minimal deps:

pip install requests  # Python

Execute Code

Run a Script

./un hello.py
./un app.js
./un main.rs

With Environment Variables

./un -e DEBUG=1 -e NAME=World script.py

With Input Files (teleport files into sandbox)

./un -f data.csv -f config.json process.py

Get Compiled Binary (teleport artifacts out)

./un -a -o ./bin main.c

Interactive Sessions

Start a Shell Session

# Default bash shell
./un session

# Choose your shell
./un session --shell zsh
./un session --shell fish

# Jump into a REPL
./un session --shell python3
./un session --shell node
./un session --shell julia

Session with Network Access

./un session -n semitrusted

Session Auditing (full terminal recording)

# Record everything (including vim, interactive programs)
./un session --audit -o ./logs

# Replay session later
zcat session.log*.gz | less -R

Collect Artifacts from Session

# Files in /tmp/artifacts/ are collected on exit
./un session -a -o ./outputs

Session Persistence (tmux/screen)

# Default: session terminates on disconnect (clean exit)
./un session

# With tmux: session persists, can reconnect later
./un session --tmux
# Press Ctrl+b then d to detach

# With screen: alternative multiplexer
./un session --screen
# Press Ctrl+a then d to detach

List Active Sessions

./un session --list

# Output:
# Active sessions: 2
#
# SESSION ID                               CONTAINER            SHELL      TTL      STATUS
# abc123...                                unsb-vm-12345        python3    45m30s   active
# def456...                                unsb-vm-67890        bash       1h2m     active

Reconnect to Existing Session

# Reconnect by container name (requires --tmux or --screen)
./un session --attach unsb-vm-12345

# Use exit to terminate session, or detach to keep it running

Terminate a Session

./un session --kill unsb-vm-12345

Available Shells & REPLs

Shells: bash, dash, sh, zsh, fish, ksh, tcsh, csh, elvish, xonsh, ash

REPLs:  python3, bpython, ipython    # Python
        node                          # JavaScript
        ruby, irb                     # Ruby
        lua                           # Lua
        php                           # PHP
        perl                          # Perl
        guile, scheme                 # Scheme
        ghci                          # Haskell
        erl, iex                      # Erlang/Elixir
        sbcl, clisp                   # Common Lisp
        r                             # R
        julia                         # Julia
        clojure                       # Clojure

API Key Management

Check Key Status

# Check if your API key is valid
./un key

# Output:
# Valid: key expires in 30 days

Extend Expired Key

# Open the portal to extend an expired key
./un key --extend

# This opens the unsandbox.com portal where you can
# add more credits to extend your key's expiration

Authentication

Credentials are loaded in priority order (highest first):

# 1. CLI flags (highest priority)
./un -p unsb-pk-xxxx -k unsb-sk-xxxxx script.py

# 2. Environment variables
export UNSANDBOX_PUBLIC_KEY=unsb-pk-xxxx-xxxx-xxxx-xxxx
export UNSANDBOX_SECRET_KEY=unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx
./un script.py

# 3. Config file (lowest priority)
# ~/.unsandbox/accounts.csv format: public_key,secret_key
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
./un script.py

Requests are signed with HMAC-SHA256. The bearer token contains only the public key; the secret key computes the signature (never transmitted).

Resource Scaling

Set vCPU Count

# Default: 1 vCPU, 2GB RAM
./un script.py

# Scale up: 4 vCPUs, 8GB RAM
./un -v 4 script.py

# Maximum: 8 vCPUs, 16GB RAM
./un --vcpu 8 heavy_compute.py

Live Session Boosting

# Boost a running session to 2 vCPU, 4GB RAM
./un session --boost sandbox-abc

# Boost to specific vCPU count (4 vCPU, 8GB RAM)
./un session --boost sandbox-abc --boost-vcpu 4

# Return to base resources (1 vCPU, 2GB RAM)
./un session --unboost sandbox-abc

Session Freeze/Unfreeze

Freeze and Unfreeze Sessions

# Freeze a session (stop billing, preserve state)
./un session --freeze sandbox-abc

# Unfreeze a frozen session
./un session --unfreeze sandbox-abc

# Note: Requires --tmux or --screen for persistence

Persistent Services

Create a Service

# Web server with ports
./un service --name web --ports 80,443 --bootstrap "python -m http.server 80"

# With custom domains
./un service --name blog --ports 8000 --domains blog.example.com

# Game server with SRV records
./un service --name mc --type minecraft --bootstrap ./setup.sh

# Deploy app tarball with bootstrap script
./un service --name app --ports 8000 -f app.tar.gz --bootstrap-file ./setup.sh
# setup.sh: cd /tmp && tar xzf app.tar.gz && ./app/start.sh

Manage Services

# List all services
./un service --list

# Get service details
./un service --info abc123

# View bootstrap logs
./un service --logs abc123
./un service --tail abc123  # last 9000 lines

# Execute command in running service
./un service --execute abc123 'journalctl -u myapp -n 50'

# Dump bootstrap script (for migrations)
./un service --dump-bootstrap abc123
./un service --dump-bootstrap abc123 backup.sh

# Freeze/unfreeze service
./un service --freeze abc123
./un service --unfreeze abc123

# Service settings (auto-wake, freeze page display)
./un service --auto-unfreeze abc123      # enable auto-wake on HTTP
./un service --no-auto-unfreeze abc123   # disable auto-wake
./un service --show-freeze-page abc123   # show HTML payment page (default)
./un service --no-show-freeze-page abc123  # return JSON error instead

# Redeploy with new bootstrap
./un service --redeploy abc123 --bootstrap ./new-setup.sh

# Destroy service
./un service --destroy abc123

Snapshots

List Snapshots

./un snapshot --list

# Output:
# Snapshots: 3
#
# SNAPSHOT ID                              NAME             SOURCE     SIZE     CREATED
# unsb-snapshot-a1b2-c3d4-e5f6-g7h8        before-upgrade   session    512 MB   2h ago
# unsb-snapshot-i9j0-k1l2-m3n4-o5p6        stable-v1.0      service    1.2 GB   1d ago

Create Session Snapshot

# Snapshot with name
./un session --snapshot unsb-vm-12345 --name "before upgrade"

# Quick snapshot (auto-generated name)
./un session --snapshot unsb-vm-12345

Create Service Snapshot

# Standard snapshot (pauses container briefly)
./un service --snapshot unsb-service-abc123 --name "stable v1.0"

# Hot snapshot (no pause, may be inconsistent)
./un service --snapshot unsb-service-abc123 --hot

Restore from Snapshot

# Restore session from snapshot
./un session --restore unsb-snapshot-a1b2-c3d4-e5f6-g7h8

# Restore service from snapshot
./un service --restore unsb-snapshot-i9j0-k1l2-m3n4-o5p6

Delete Snapshot

./un snapshot --delete unsb-snapshot-a1b2-c3d4-e5f6-g7h8

Images

Images are independent, transferable container images that survive container deletion. Unlike snapshots (which live with their container), images can be shared with other users, transferred between API keys, or made public in the marketplace.

List Images

# List all images (owned + shared + public)
./un image --list

# List only your images
./un image --list owned

# List images shared with you
./un image --list shared

# List public marketplace images
./un image --list public

# Get image details
./un image --info unsb-image-xxxx-xxxx-xxxx-xxxx

Publish Images

# Publish from a stopped or frozen service
./un image --publish-service unsb-service-abc123 \
   --name "My App v1.0" --description "Production snapshot"

# Publish from a snapshot
./un image --publish-snapshot unsb-snapshot-xxxx-xxxx-xxxx-xxxx \
   --name "Stable Release"

# Note: Cannot publish from running containers - stop or freeze first

Create Services from Images

# Spawn a new service from an image
./un image --spawn unsb-image-xxxx-xxxx-xxxx-xxxx \
   --name new-service --ports 80,443

# Clone an image (creates a copy you own)
./un image --clone unsb-image-xxxx-xxxx-xxxx-xxxx

Image Protection

# Lock image to prevent accidental deletion
./un image --lock unsb-image-xxxx-xxxx-xxxx-xxxx

# Unlock image to allow deletion
./un image --unlock unsb-image-xxxx-xxxx-xxxx-xxxx

# Delete image (must be unlocked)
./un image --delete unsb-image-xxxx-xxxx-xxxx-xxxx

Visibility & Sharing

# Set visibility level
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx private   # owner only (default)
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx unlisted  # can be shared
./un image --visibility unsb-image-xxxx-xxxx-xxxx-xxxx public    # marketplace

# Share with specific user
./un image --grant unsb-image-xxxx-xxxx-xxxx-xxxx \
   --key unsb-pk-friend-friend-friend-friend

# Revoke access
./un image --revoke unsb-image-xxxx-xxxx-xxxx-xxxx \
   --key unsb-pk-friend-friend-friend-friend

# List who has access
./un image --trusted unsb-image-xxxx-xxxx-xxxx-xxxx

Transfer Ownership

# Transfer image to another API key
./un image --transfer unsb-image-xxxx-xxxx-xxxx-xxxx \
   --to unsb-pk-newowner-newowner-newowner-newowner

Usage Reference

Usage: ./un [options] <source_file>
       ./un session [options]
       ./un service [options]
       ./un snapshot [options]
       ./un image [options]
       ./un key

Commands:
  (default)        Execute source file in sandbox
  session          Open interactive shell/REPL session
  service          Manage persistent services
  snapshot         Manage container snapshots
  image            Manage container images (publish, share, transfer)
  key              Check API key validity and expiration

Options:
  -e KEY=VALUE     Set environment variable (can use multiple times)
  -f FILE          Add input file (can use multiple times)
  -a               Return and save artifacts from /tmp/artifacts/
  -o DIR           Output directory for artifacts (default: current dir)
  -p KEY           Public key (or set UNSANDBOX_PUBLIC_KEY env var)
  -k KEY           Secret key (or set UNSANDBOX_SECRET_KEY env var)
  -n MODE          Network mode: zerotrust (default) or semitrusted
  -v N, --vcpu N   vCPU count 1-8, each vCPU gets 2GB RAM (default: 1)
  -y               Skip confirmation for large uploads (>1GB)
  -h               Show this help

Authentication (priority order):
  1. -p and -k flags (public and secret key)
  2. UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY env vars
  3. ~/.unsandbox/accounts.csv (format: public_key,secret_key per line)

Session options:
  -s, --shell SHELL  Shell/REPL to use (default: bash)
  -l, --list         List active sessions
  --attach ID        Reconnect to existing session (ID or container name)
  --kill ID          Terminate a session (ID or container name)
  --freeze ID        Freeze a session (requires --tmux/--screen)
  --unfreeze ID      Unfreeze a frozen session
  --boost ID         Boost session resources (2 vCPU, 4GB RAM)
  --boost-vcpu N     Specify vCPU count for boost (1-8)
  --unboost ID       Return to base resources
  --audit            Record full session for auditing
  --tmux             Enable session persistence with tmux (allows reconnect)
  --screen           Enable session persistence with screen (allows reconnect)

Service options:
  --name NAME        Service name (creates new service)
  --ports PORTS      Comma-separated ports (e.g., 80,443)
  --domains DOMAINS  Custom domains (e.g., example.com,www.example.com)
  --type TYPE        Service type: minecraft, mumble, teamspeak, source, tcp, udp
  --bootstrap CMD    Bootstrap command/file/URL to run on startup
  -f FILE            Upload file to /tmp/ (can use multiple times)
  -l, --list         List all services
  --info ID          Get service details
  --tail ID          Get last 9000 lines of bootstrap logs
  --logs ID          Get all bootstrap logs
  --freeze ID        Freeze a service
  --unfreeze ID      Unfreeze a service
  --auto-unfreeze ID       Enable auto-wake on HTTP request
  --no-auto-unfreeze ID    Disable auto-wake on HTTP request
  --show-freeze-page ID    Show HTML payment page when frozen (default)
  --no-show-freeze-page ID Return JSON error when frozen
  --destroy ID       Destroy a service
  --redeploy ID      Re-run bootstrap script (requires --bootstrap)
  --execute ID CMD   Run a command in a running service
  --dump-bootstrap ID [FILE]  Dump bootstrap script (for migrations)
  --snapshot ID    Create snapshot of session or service
  --snapshot-name  User-friendly name for snapshot
  --hot            Create snapshot without pausing (may be inconsistent)
  --restore ID     Restore session/service from snapshot ID

Snapshot options:
  -l, --list       List all snapshots
  --info ID        Get snapshot details
  --delete ID      Delete a snapshot permanently

Image options:
  -l, --list [owned|shared|public]  List images (all, owned, shared, or public)
  --info ID        Get image details
  --publish-service ID   Publish image from stopped/frozen service
  --publish-snapshot ID  Publish image from snapshot
  --name NAME      Name for published image
  --description DESC  Description for published image
  --delete ID      Delete image (must be unlocked)
  --clone ID       Clone image (creates copy you own)
  --spawn ID       Create service from image (requires --name)
  --lock ID        Lock image to prevent deletion
  --unlock ID      Unlock image to allow deletion
  --visibility ID LEVEL  Set visibility (private|unlisted|public)
  --grant ID --key KEY   Grant access to another API key
  --revoke ID --key KEY  Revoke access from API key
  --transfer ID --to KEY Transfer ownership to API key
  --trusted ID     List API keys with access

Key options:
  (no options)       Check API key validity
  --extend           Open portal to extend an expired key

Examples:
  ./un script.py                       # execute Python script
  ./un -e DEBUG=1 script.py            # with environment variable
  ./un -f data.csv process.py          # with input file
  ./un -a -o ./bin main.c              # save compiled artifacts
  ./un -v 4 heavy.py                   # with 4 vCPUs, 8GB RAM
  ./un session                         # interactive bash session
  ./un session --tmux                  # bash with reconnect support
  ./un session --list                  # list active sessions
  ./un session --attach unsb-vm-12345  # reconnect to session
  ./un session --kill unsb-vm-12345    # terminate a session
  ./un session --freeze unsb-vm-12345  # freeze session
  ./un session --unfreeze unsb-vm-12345  # unfreeze session
  ./un session --boost unsb-vm-12345   # boost resources
  ./un session --unboost unsb-vm-12345 # return to base
  ./un session --shell python3         # Python REPL
  ./un session --shell node            # Node.js REPL
  ./un session -n semitrusted          # session with network access
  ./un session --audit -o ./logs       # record session for auditing
  ./un service --name web --ports 80   # create web service
  ./un service --list                  # list all services
  ./un service --logs abc123           # view bootstrap logs
  ./un key                             # check API key
  ./un key --extend                    # extend expired key
  ./un snapshot --list                 # list all snapshots
  ./un session --snapshot unsb-vm-123  # snapshot a session
  ./un service --snapshot abc123       # snapshot a service
  ./un session --restore unsb-snapshot-xxxx  # restore from snapshot
  ./un image --list                  # list all images
  ./un image --list owned            # list your images
  ./un image --publish-service abc   # publish image from service
  ./un image --spawn img123 --name x # create service from image
  ./un image --grant img --key pk    # share image with user

CLI Inception

The UN CLI has been implemented in 42 programming languages, demonstrating that the unsandbox API can be accessed from virtually any environment.

View All 42 Implementations →

License

PUBLIC DOMAIN - NO LICENSE, NO WARRANTY

This is free public domain software for the public good of a permacomputer hosted
at permacomputer.com - an always-on computer by the people, for the people. One
that is durable, easy to repair, and distributed like tap water for machine
learning intelligence.

The permacomputer is community-owned infrastructure optimized around four values:

  TRUTH    - First principles, math & science, open source code freely distributed
  FREEDOM  - Voluntary partnerships, freedom from tyranny & corporate control
  HARMONY  - Minimal waste, self-renewing systems with diverse thriving connections
  LOVE     - Be yourself without hurting others, cooperation through natural law

This software contributes to that vision by enabling code execution across all 42
programming languages through a unified interface, accessible to everyone. Code is
seeds to sprout on any abandoned technology.

Learn more: https://www.permacomputer.com

Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.

NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.

That said, our permacomputer's digital membrane stratum continuously runs unit,
integration, and functional tests on all its own software - with our permacomputer
monitoring itself, repairing itself, with minimal human guidance in the loop.
Our agents do their best.

Copyright 2025 TimeHexOn & foxhop & russell@unturf
https://www.timehexon.com
https://www.foxhop.net
https://www.unturf.com/software

Export Vault

Enter a password to encrypt your exported vault. You'll need this password to import the vault on another device.

Import Vault

Select an exported vault file and enter the export password to decrypt it.