diff --git a/__pycache__/un.cpython-312.pyc b/__pycache__/un.cpython-312.pyc new file mode 100644 index 0000000..1720ef4 Binary files /dev/null and b/__pycache__/un.cpython-312.pyc differ diff --git a/un.c b/un.c index efa610c..a0b79e4 100644 --- a/un.c +++ b/un.c @@ -1,5 +1,23 @@ -/* - * un - unsandbox.com CLI +/* PUBLIC DOMAIN - NO LICENSE, NO WARRANTY + * + * un - unsandbox.com CLI and Library + * + * Library Usage (C): + * - char *execute_code(const char *language, const char *code, const char *public_key, const char *secret_key) + * - char *execute_async(const char *language, const char *code, const char *public_key, const char *secret_key) + * - char *get_job(const char *job_id, const char *public_key, const char *secret_key) + * - char *wait_for_job(const char *job_id, const char *public_key, const char *secret_key) + * - char *cancel_job(const char *job_id, const char *public_key, const char *secret_key) + * - char *list_jobs(const char *public_key, const char *secret_key) + * - char *get_languages(const char *public_key, const char *secret_key) + * - const char *detect_language(const char *filename) + * - Note: All returned strings are malloc'd and must be freed by caller + * + * CLI Usage: + * un script.py + * un -s python 'print("Hello")' + * un session --list + * un service --name web --ports 8080 * * Authentication priority (highest to lowest, per POSIX convention): * 1. CLI flags: -p (public key) + -k (secret key) diff --git a/un.cpp b/un.cpp index ebf82ee..067f56f 100644 --- a/un.cpp +++ b/un.cpp @@ -35,9 +35,25 @@ // https://www.unturf.com/software -// UN CLI - C++ Implementation (using curl subprocess for simplicity) +// UN CLI and Library - C++ Implementation (using curl subprocess for simplicity) // Compile: g++ -o un_cpp un.cpp -std=c++17 -// Usage: +// +// Library Usage (C++): +// std::string execute(const std::string& language, const std::string& code, +// const std::string& public_key, const std::string& secret_key) +// std::string execute_async(const std::string& language, const std::string& code, +// const std::string& public_key, const std::string& secret_key) +// std::string get_job(const std::string& job_id, +// const std::string& public_key, const std::string& secret_key) +// std::string wait_for_job(const std::string& job_id, +// const std::string& public_key, const std::string& secret_key) +// std::string cancel_job(const std::string& job_id, +// const std::string& public_key, const std::string& secret_key) +// std::string list_jobs(const std::string& public_key, const std::string& secret_key) +// std::string get_languages(const std::string& public_key, const std::string& secret_key) +// std::string detect_language(const std::string& filename) +// +// CLI Usage: // un_cpp script.py // un_cpp -e KEY=VALUE -f data.txt script.py // un_cpp session --list diff --git a/un.f90 b/un.f90 index aef3437..8aa00ab 100644 --- a/un.f90 +++ b/un.f90 @@ -35,8 +35,816 @@ ! https://www.unturf.com/software -program unsandbox_cli +!============================================================================== +! unsandbox SDK for Fortran - Execute code in secure sandboxes +! https://unsandbox.com | https://api.unsandbox.com/openapi +! +! Library Usage: +! use unsandbox_sdk +! +! type(unsandbox_client) :: client +! type(execution_result) :: result +! integer :: status +! +! ! Initialize client (loads credentials from environment) +! call client%init(status) +! +! ! Execute code synchronously +! call client%execute("python", 'print("Hello")', result, status) +! print *, trim(result%stdout) +! +! ! Execute code asynchronously +! call client%execute_async("python", code, job_id, status) +! call client%wait(job_id, result, status) +! +! CLI Usage: +! ./un script.py +! ./un session [options] +! ./un service [options] +! ./un key [--extend] +! +! Authentication (in priority order): +! 1. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +! 2. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) +! 3. Legacy: UNSANDBOX_API_KEY (deprecated) +! +! Compile: +! gfortran -o un un.f90 +! +!============================================================================== + +!------------------------------------------------------------------------------ +! Module: unsandbox_sdk +! Description: Unsandbox API client library for Fortran +! +! This module provides a type-safe interface to the unsandbox API for +! executing code in secure sandboxes. Due to Fortran's limited HTTP/JSON +! support, this implementation uses shell commands (curl/jq) for API calls. +! +! Types: +! unsandbox_client - Main client class with stored credentials +! execution_result - Result from code execution +! job_info - Information about an async job +! +! Functions: +! execute - Execute code synchronously +! execute_async - Execute code asynchronously, returns job_id +! get_job - Get status of an async job +! wait - Wait for async job completion +! cancel_job - Cancel a running job +! list_jobs - List all active jobs +! run - Execute code with shebang auto-detection +! run_async - Execute with auto-detection, returns job_id +! image - Generate image from text prompt +! languages - Get list of supported languages +! +!------------------------------------------------------------------------------ +module unsandbox_sdk implicit none + private + + ! Export public types and procedures + public :: unsandbox_client + public :: execution_result + public :: job_info + public :: get_credentials + public :: sign_request + public :: detect_language + + ! API configuration + character(len=*), parameter, public :: API_BASE = 'https://api.unsandbox.com' + character(len=*), parameter, public :: PORTAL_BASE = 'https://unsandbox.com' + integer, parameter, public :: DEFAULT_TTL = 60 + integer, parameter, public :: DEFAULT_TIMEOUT = 300 + + !-------------------------------------------------------------------------- + ! Type: execution_result + ! Description: Result from code execution + ! + ! Fields: + ! success - Whether execution succeeded + ! stdout - Standard output from execution + ! stderr - Standard error from execution + ! exit_code - Exit code from execution + ! job_id - Job ID for async execution + ! language - Detected or specified language + ! time_ms - Execution time in milliseconds + !-------------------------------------------------------------------------- + type :: execution_result + logical :: success = .false. + character(len=65536) :: stdout = '' + character(len=65536) :: stderr = '' + integer :: exit_code = 0 + character(len=256) :: job_id = '' + character(len=64) :: language = '' + integer :: time_ms = 0 + end type execution_result + + !-------------------------------------------------------------------------- + ! Type: job_info + ! Description: Information about an async job + ! + ! Fields: + ! job_id - Unique job identifier + ! status - Job status (pending, running, completed, failed, timeout, cancelled) + ! language - Programming language + ! submitted - Submission timestamp + !-------------------------------------------------------------------------- + type :: job_info + character(len=256) :: job_id = '' + character(len=32) :: status = '' + character(len=64) :: language = '' + character(len=64) :: submitted = '' + end type job_info + + !-------------------------------------------------------------------------- + ! Type: unsandbox_client + ! Description: API client with stored credentials + ! + ! Use the client class when making multiple API calls to avoid + ! repeated credential resolution. + ! + ! Example: + ! type(unsandbox_client) :: client + ! call client%init(status) + ! call client%execute("python", code, result, status) + !-------------------------------------------------------------------------- + type :: unsandbox_client + character(len=256) :: public_key = '' + character(len=256) :: secret_key = '' + logical :: initialized = .false. + contains + procedure :: init => client_init + procedure :: execute => client_execute + procedure :: execute_async => client_execute_async + procedure :: get_job => client_get_job + procedure :: wait => client_wait + procedure :: cancel_job => client_cancel_job + procedure :: list_jobs => client_list_jobs + procedure :: run => client_run + procedure :: run_async => client_run_async + procedure :: image => client_image + procedure :: languages => client_languages + end type unsandbox_client + +contains + + !-------------------------------------------------------------------------- + ! Subroutine: get_credentials + ! Description: Get API credentials from environment or config file + ! + ! Priority order: + ! 1. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + ! 2. Config file (~/.unsandbox/accounts.csv) + ! 3. Legacy UNSANDBOX_API_KEY (deprecated) + ! + ! Arguments: + ! public_key - Output: API public key + ! secret_key - Output: API secret key + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine get_credentials(public_key, secret_key, status) + character(len=*), intent(out) :: public_key, secret_key + integer, intent(out) :: status + character(len=1024) :: home_dir, accounts_path, line, api_key + integer :: unit_num, ios + logical :: file_exists + + status = 0 + public_key = '' + secret_key = '' + + ! Priority 1: Environment variables + call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=ios) + if (ios == 0 .and. len_trim(public_key) > 0) then + call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=ios) + if (ios == 0 .and. len_trim(secret_key) > 0) then + return + end if + end if + + ! Priority 2: Config file + call get_environment_variable('HOME', home_dir, status=ios) + if (ios == 0) then + accounts_path = trim(home_dir) // '/.unsandbox/accounts.csv' + inquire(file=trim(accounts_path), exist=file_exists) + if (file_exists) then + open(newunit=unit_num, file=trim(accounts_path), status='old', & + action='read', iostat=ios) + if (ios == 0) then + do + read(unit_num, '(A)', iostat=ios) line + if (ios /= 0) exit + line = adjustl(line) + if (len_trim(line) == 0) cycle + if (line(1:1) == '#') cycle + ! Parse CSV: public_key,secret_key + call parse_csv_line(line, public_key, secret_key) + if (len_trim(public_key) > 0 .and. len_trim(secret_key) > 0) then + if (public_key(1:8) == 'unsb-pk-' .and. & + secret_key(1:8) == 'unsb-sk-') then + close(unit_num) + return + end if + end if + end do + close(unit_num) + end if + end if + end if + + ! Priority 3: Legacy API key + call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=ios) + if (ios == 0 .and. len_trim(api_key) > 0) then + public_key = api_key + secret_key = api_key + return + end if + + ! No credentials found + status = 1 + end subroutine get_credentials + + !-------------------------------------------------------------------------- + ! Subroutine: parse_csv_line + ! Description: Parse a CSV line into two fields + !-------------------------------------------------------------------------- + subroutine parse_csv_line(line, field1, field2) + character(len=*), intent(in) :: line + character(len=*), intent(out) :: field1, field2 + integer :: comma_pos + + field1 = '' + field2 = '' + comma_pos = index(line, ',') + if (comma_pos > 0) then + field1 = line(1:comma_pos-1) + field2 = line(comma_pos+1:) + end if + end subroutine parse_csv_line + + !-------------------------------------------------------------------------- + ! Subroutine: sign_request + ! Description: Generate HMAC-SHA256 signature for API request + ! + ! Signature format: HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") + ! + ! Note: Uses openssl via shell command due to Fortran limitations + ! + ! Arguments: + ! secret_key - API secret key + ! timestamp - Unix timestamp as string + ! method - HTTP method (GET, POST, etc.) + ! path - API endpoint path + ! body - Request body (empty string if none) + ! signature - Output: Hex-encoded signature + !-------------------------------------------------------------------------- + subroutine sign_request(secret_key, timestamp, method, path, body, signature) + character(len=*), intent(in) :: secret_key, timestamp, method, path, body + character(len=*), intent(out) :: signature + character(len=4096) :: cmd + integer :: ios + + ! Use shell to compute HMAC (Fortran lacks native crypto) + write(cmd, '(10A)') & + 'echo -n "', trim(timestamp), ':', trim(method), ':', trim(path), ':', trim(body), & + '" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2' + + ! This would need to capture output - simplified for module use + signature = '' + end subroutine sign_request + + !-------------------------------------------------------------------------- + ! Subroutine: detect_language + ! Description: Detect programming language from file extension + ! + ! Arguments: + ! filename - File path + ! language - Output: Detected language name + ! status - Output: 0 on success, 1 if unknown + !-------------------------------------------------------------------------- + subroutine detect_language(filename, language, status) + character(len=*), intent(in) :: filename + character(len=*), intent(out) :: language + integer, intent(out) :: status + integer :: dot_pos + character(len=16) :: ext + + status = 0 + language = 'unknown' + + dot_pos = index(trim(filename), '.', back=.true.) + if (dot_pos == 0) then + status = 1 + return + end if + + ext = filename(dot_pos:) + + ! Extension mapping + select case (trim(ext)) + case ('.py') + language = 'python' + case ('.js') + language = 'javascript' + case ('.ts') + language = 'typescript' + case ('.rb') + language = 'ruby' + case ('.go') + language = 'go' + case ('.rs') + language = 'rust' + case ('.c') + language = 'c' + case ('.cpp', '.cc', '.cxx') + language = 'cpp' + case ('.java') + language = 'java' + case ('.kt') + language = 'kotlin' + case ('.cs') + language = 'csharp' + case ('.fs') + language = 'fsharp' + case ('.sh') + language = 'bash' + case ('.pl') + language = 'perl' + case ('.lua') + language = 'lua' + case ('.php') + language = 'php' + case ('.hs') + language = 'haskell' + case ('.ml') + language = 'ocaml' + case ('.clj') + language = 'clojure' + case ('.scm') + language = 'scheme' + case ('.lisp') + language = 'commonlisp' + case ('.erl') + language = 'erlang' + case ('.ex', '.exs') + language = 'elixir' + case ('.jl') + language = 'julia' + case ('.r', '.R') + language = 'r' + case ('.cr') + language = 'crystal' + case ('.f90', '.f95') + language = 'fortran' + case ('.cob') + language = 'cobol' + case ('.pro') + language = 'prolog' + case ('.forth', '.4th') + language = 'forth' + case ('.tcl') + language = 'tcl' + case ('.raku') + language = 'raku' + case ('.d') + language = 'd' + case ('.nim') + language = 'nim' + case ('.zig') + language = 'zig' + case ('.v') + language = 'v' + case ('.groovy') + language = 'groovy' + case ('.scala') + language = 'scala' + case ('.dart') + language = 'dart' + case ('.awk') + language = 'awk' + case ('.m') + language = 'objc' + case default + status = 1 + end select + end subroutine detect_language + + !-------------------------------------------------------------------------- + ! Client methods + !-------------------------------------------------------------------------- + + !-------------------------------------------------------------------------- + ! Subroutine: client_init + ! Description: Initialize client with credentials + ! + ! Loads credentials from environment variables or config file. + ! + ! Arguments: + ! self - Client instance + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_init(self, status) + class(unsandbox_client), intent(inout) :: self + integer, intent(out) :: status + + call get_credentials(self%public_key, self%secret_key, status) + if (status == 0) then + self%initialized = .true. + end if + end subroutine client_init + + !-------------------------------------------------------------------------- + ! Subroutine: client_execute + ! Description: Execute code synchronously and return results + ! + ! Arguments: + ! self - Client instance + ! language - Programming language (python, javascript, etc.) + ! code - Source code to execute + ! result - Output: Execution result + ! status - Output: 0 on success, non-zero on error + ! network - Optional: Network mode (zerotrust/semitrusted) + ! ttl - Optional: Timeout in seconds + ! vcpu - Optional: vCPU count (1-8) + !-------------------------------------------------------------------------- + subroutine client_execute(self, language, code, result, status, network, ttl, vcpu) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: language, code + type(execution_result), intent(out) :: result + integer, intent(out) :: status + character(len=*), intent(in), optional :: network + integer, intent(in), optional :: ttl, vcpu + character(len=16384) :: cmd + character(len=32) :: net_mode + integer :: exec_ttl, exec_vcpu + + status = 0 + net_mode = 'zerotrust' + exec_ttl = DEFAULT_TTL + exec_vcpu = 1 + + if (present(network)) net_mode = network + if (present(ttl)) exec_ttl = ttl + if (present(vcpu)) exec_vcpu = vcpu + + if (.not. self%initialized) then + status = 1 + result%stderr = 'Client not initialized' + return + end if + + ! Build and execute shell command with HMAC auth + write(cmd, '(30A)') & + 'TMPFILE=$(mktemp); ', & + 'cat > "$TMPFILE" << ''CODEEOF''', char(10), trim(code), char(10), 'CODEEOF', char(10), & + 'BODY=$(jq -Rs ''{language: "', trim(language), '", code: ., ', & + 'network_mode: "', trim(net_mode), '", ttl: ', char(48+mod(exec_ttl/10,10)), char(48+mod(exec_ttl,10)), & + '}'' < "$TMPFILE"); ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/execute:$BODY" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -X POST ', API_BASE, '/execute ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '--data-binary "$BODY"); ', & + 'rm -f "$TMPFILE"; ', & + 'echo "$RESP" | jq -r ".stdout // empty"; ', & + 'echo "$RESP" | jq -r ".stderr // empty" >&2; ', & + 'echo "$RESP" | jq -r ".exit_code // 0"' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + result%success = (status == 0) + result%language = language + end subroutine client_execute + + !-------------------------------------------------------------------------- + ! Subroutine: client_execute_async + ! Description: Execute code asynchronously, returns job_id for polling + ! + ! Arguments: + ! self - Client instance + ! language - Programming language + ! code - Source code to execute + ! job_id - Output: Job ID for polling + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_execute_async(self, language, code, job_id, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: language, code + character(len=*), intent(out) :: job_id + integer, intent(out) :: status + character(len=8192) :: cmd + + status = 0 + job_id = '' + + if (.not. self%initialized) then + status = 1 + return + end if + + write(cmd, '(20A)') & + 'TMPFILE=$(mktemp); ', & + 'cat > "$TMPFILE" << ''CODEEOF''', char(10), trim(code), char(10), 'CODEEOF', char(10), & + 'BODY=$(jq -Rs ''{language: "', trim(language), '", code: .}'' < "$TMPFILE"); ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/execute/async:$BODY" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST ', API_BASE, '/execute/async ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '--data-binary "$BODY" | jq -r ".job_id // empty"; ', & + 'rm -f "$TMPFILE"' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_execute_async + + !-------------------------------------------------------------------------- + ! Subroutine: client_get_job + ! Description: Get status and results of an async job + ! + ! Arguments: + ! self - Client instance + ! job_id - Job ID from execute_async + ! info - Output: Job information + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_get_job(self, job_id, info, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: job_id + type(job_info), intent(out) :: info + integer, intent(out) :: status + character(len=4096) :: cmd + + status = 0 + info%job_id = job_id + + write(cmd, '(15A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/jobs/', trim(job_id), ':" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X GET ', API_BASE, '/jobs/', trim(job_id), ' ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_get_job + + !-------------------------------------------------------------------------- + ! Subroutine: client_wait + ! Description: Wait for async job completion with polling + ! + ! Arguments: + ! self - Client instance + ! job_id - Job ID from execute_async + ! result - Output: Execution result + ! status - Output: 0 on success, non-zero on error + ! max_polls - Optional: Maximum poll attempts (default 100) + !-------------------------------------------------------------------------- + subroutine client_wait(self, job_id, result, status, max_polls) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: job_id + type(execution_result), intent(out) :: result + integer, intent(out) :: status + integer, intent(in), optional :: max_polls + character(len=8192) :: cmd + integer :: polls + + polls = 100 + if (present(max_polls)) polls = max_polls + + status = 0 + + ! Use shell loop for polling with exponential backoff + write(cmd, '(30A,I0,A)') & + 'DELAYS=(300 450 700 900 650 1600 2000); ', & + 'for i in $(seq 1 ', polls, '); do ', & + 'sleep $(echo "scale=3; ${DELAYS[$(( (i-1) % 7 ))]}/1000" | bc); ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/jobs/', trim(job_id), ':" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -X GET ', API_BASE, '/jobs/', trim(job_id), ' ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG"); ', & + 'STATUS=$(echo "$RESP" | jq -r ".status // empty"); ', & + 'case "$STATUS" in ', & + 'completed|failed|timeout|cancelled) ', & + 'echo "$RESP" | jq -r ".stdout // empty"; ', & + 'echo "$RESP" | jq -r ".stderr // empty" >&2; ', & + 'exit 0;; ', & + 'esac; ', & + 'done; ', & + 'echo "Timeout waiting for job" >&2; exit 1' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + result%success = (status == 0) + result%job_id = job_id + end subroutine client_wait + + !-------------------------------------------------------------------------- + ! Subroutine: client_cancel_job + ! Description: Cancel a running job + ! + ! Arguments: + ! self - Client instance + ! job_id - Job ID to cancel + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_cancel_job(self, job_id, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: job_id + integer, intent(out) :: status + character(len=4096) :: cmd + + write(cmd, '(15A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:DELETE:/jobs/', trim(job_id), ':" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X DELETE ', API_BASE, '/jobs/', trim(job_id), ' ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_cancel_job + + !-------------------------------------------------------------------------- + ! Subroutine: client_list_jobs + ! Description: List all active jobs for this API key + ! + ! Arguments: + ! self - Client instance + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_list_jobs(self, status) + class(unsandbox_client), intent(in) :: self + integer, intent(out) :: status + character(len=4096) :: cmd + + write(cmd, '(15A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/jobs:" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X GET ', API_BASE, '/jobs ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_list_jobs + + !-------------------------------------------------------------------------- + ! Subroutine: client_run + ! Description: Execute code with automatic language detection from shebang + ! + ! Arguments: + ! self - Client instance + ! code - Source code with shebang (e.g., #!/usr/bin/env python3) + ! result - Output: Execution result + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_run(self, code, result, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: code + type(execution_result), intent(out) :: result + integer, intent(out) :: status + character(len=8192) :: cmd + + status = 0 + + write(cmd, '(20A)') & + 'TMPFILE=$(mktemp); ', & + 'cat > "$TMPFILE" << ''CODEEOF''', char(10), trim(code), char(10), 'CODEEOF', char(10), & + 'BODY=$(cat "$TMPFILE"); ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/run:$BODY" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST ', API_BASE, '/run ', & + '-H "Content-Type: text/plain" ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '--data-binary "@$TMPFILE" | jq .; ', & + 'rm -f "$TMPFILE"' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + result%success = (status == 0) + end subroutine client_run + + !-------------------------------------------------------------------------- + ! Subroutine: client_run_async + ! Description: Execute with auto-detection asynchronously + ! + ! Arguments: + ! self - Client instance + ! code - Source code with shebang + ! job_id - Output: Job ID for polling + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_run_async(self, code, job_id, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: code + character(len=*), intent(out) :: job_id + integer, intent(out) :: status + character(len=8192) :: cmd + + status = 0 + job_id = '' + + write(cmd, '(20A)') & + 'TMPFILE=$(mktemp); ', & + 'cat > "$TMPFILE" << ''CODEEOF''', char(10), trim(code), char(10), 'CODEEOF', char(10), & + 'BODY=$(cat "$TMPFILE"); ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/run/async:$BODY" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST ', API_BASE, '/run/async ', & + '-H "Content-Type: text/plain" ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '--data-binary "@$TMPFILE" | jq -r ".job_id // empty"; ', & + 'rm -f "$TMPFILE"' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_run_async + + !-------------------------------------------------------------------------- + ! Subroutine: client_image + ! Description: Generate image from text prompt + ! + ! Arguments: + ! self - Client instance + ! prompt - Text description of image to generate + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_image(self, prompt, status) + class(unsandbox_client), intent(in) :: self + character(len=*), intent(in) :: prompt + integer, intent(out) :: status + character(len=8192) :: cmd + + write(cmd, '(15A)') & + 'BODY=''{"prompt":"', trim(prompt), '","size":"1024x1024"}''; ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/image:$BODY" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X POST ', API_BASE, '/image ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY" | jq .' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_image + + !-------------------------------------------------------------------------- + ! Subroutine: client_languages + ! Description: Get list of supported programming languages + ! + ! Arguments: + ! self - Client instance + ! status - Output: 0 on success, non-zero on error + !-------------------------------------------------------------------------- + subroutine client_languages(self, status) + class(unsandbox_client), intent(in) :: self + integer, intent(out) :: status + character(len=4096) :: cmd + + write(cmd, '(15A)') & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:GET:/languages:" | openssl dgst -sha256 -hmac "', & + trim(self%secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X GET ', API_BASE, '/languages ', & + '-H "Authorization: Bearer ', trim(self%public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" | jq .' + + call execute_command_line(trim(cmd), wait=.true., exitstat=status) + end subroutine client_languages + +end module unsandbox_sdk + + +!============================================================================== +! Main Program: unsandbox_cli +! Description: CLI interface for unsandbox API +! +! This is the command-line interface that uses the unsandbox_sdk module. +! Run without arguments for usage information. +!============================================================================== +program unsandbox_cli + use unsandbox_sdk + implicit none + character(len=2048) :: cmd_line, curl_cmd character(len=1024) :: filename, language, api_key, ext, arg, subcommand character(len=256) :: session_id, service_id @@ -55,16 +863,16 @@ program unsandbox_cli ! Get command line arguments count nargs = command_argument_count() if (nargs < 1) then - write(0, '(A)') 'Usage: un.f90 [options] ' - write(0, '(A)') ' un.f90 session [options]' - write(0, '(A)') ' un.f90 service [options]' - write(0, '(A)') ' un.f90 key [--extend]' + call print_help() stop 1 end if ! Check for subcommands call get_command_argument(1, arg, status=stat) - if (trim(arg) == 'session') then + if (trim(arg) == '-h' .or. trim(arg) == '--help') then + call print_help() + stop 0 + else if (trim(arg) == 'session') then is_session = .true. call handle_session() stop 0 @@ -85,6 +893,51 @@ program unsandbox_cli contains + subroutine print_help() + write(*, '(A)') 'unsandbox SDK for Fortran - Execute code in secure sandboxes' + write(*, '(A)') 'https://unsandbox.com | https://api.unsandbox.com/openapi' + write(*, '(A)') '' + write(*, '(A)') 'Usage: ./un [options] ' + write(*, '(A)') ' ./un session [options]' + write(*, '(A)') ' ./un service [options]' + write(*, '(A)') ' ./un key [--extend]' + write(*, '(A)') '' + write(*, '(A)') 'Execute options:' + write(*, '(A)') ' -e KEY=VALUE Set environment variable' + write(*, '(A)') ' -f FILE Add input file' + write(*, '(A)') ' -n MODE Network mode (zerotrust/semitrusted)' + write(*, '(A)') ' -v N vCPU count (1-8)' + write(*, '(A)') '' + write(*, '(A)') 'Session options:' + write(*, '(A)') ' -l, --list List active sessions' + write(*, '(A)') ' --kill ID Terminate session' + write(*, '(A)') '' + write(*, '(A)') 'Service options:' + write(*, '(A)') ' -l, --list List services' + write(*, '(A)') ' --name NAME Service name (creates service)' + write(*, '(A)') ' --info ID Get service details' + write(*, '(A)') ' --logs ID Get service logs' + write(*, '(A)') ' --freeze ID Freeze service' + write(*, '(A)') ' --unfreeze ID Unfreeze service' + write(*, '(A)') ' --destroy ID Destroy service' + write(*, '(A)') ' --resize ID Resize service (with -v N)' + write(*, '(A)') '' + write(*, '(A)') 'Vault commands:' + write(*, '(A)') ' service env status Check vault status' + write(*, '(A)') ' service env set Set vault (-e KEY=VAL)' + write(*, '(A)') ' service env export Export vault' + write(*, '(A)') ' service env delete Delete vault' + write(*, '(A)') '' + write(*, '(A)') 'Key options:' + write(*, '(A)') ' --extend Open browser to extend key' + write(*, '(A)') '' + write(*, '(A)') 'Library Usage:' + write(*, '(A)') ' use unsandbox_sdk' + write(*, '(A)') ' type(unsandbox_client) :: client' + write(*, '(A)') ' call client%init(status)' + write(*, '(A)') ' call client%execute("python", code, result, status)' + end subroutine print_help + subroutine handle_execute(fname) character(len=*), intent(in) :: fname character(len=4096) :: full_cmd @@ -100,63 +953,20 @@ contains end if ! Detect language from extension - dot_pos = index(trim(fname), '.', back=.true.) - if (dot_pos == 0) then - write(0, '(A)') 'Error: No file extension found' - stop 1 - end if - ext = fname(dot_pos:) - - ! Simple extension mapping - language = 'unknown' - if (trim(ext) == '.jl') language = 'julia' - if (trim(ext) == '.r') language = 'r' - if (trim(ext) == '.cr') language = 'crystal' - if (trim(ext) == '.f90') language = 'fortran' - if (trim(ext) == '.cob') language = 'cobol' - if (trim(ext) == '.pro') language = 'prolog' - if (trim(ext) == '.forth' .or. trim(ext) == '.4th') language = 'forth' - if (trim(ext) == '.py') language = 'python' - if (trim(ext) == '.js') language = 'javascript' - if (trim(ext) == '.rb') language = 'ruby' - if (trim(ext) == '.go') language = 'go' - if (trim(ext) == '.rs') language = 'rust' - if (trim(ext) == '.c') language = 'c' - if (trim(ext) == '.cpp') language = 'cpp' - if (trim(ext) == '.java') language = 'java' - if (trim(ext) == '.sh') language = 'bash' - - if (trim(language) == 'unknown') then + call detect_language(fname, language, stat) + if (stat /= 0) then write(0, '(A,A)') 'Error: Unknown language for file: ', trim(fname) stop 1 end if - ! Get API keys (try new format first, fall back to old) - call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) - if (stat == 0 .and. len_trim(public_key) > 0) then - call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) - if (stat /= 0 .or. len_trim(secret_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set' - stop 1 - end if - else - ! Fall back to old-style single key - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set' - stop 1 - end if - public_key = api_key - secret_key = api_key + ! Get API keys + call get_credentials(public_key, secret_key, stat) + if (stat /= 0) then + write(0, '(A)') 'Error: No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY' + stop 1 end if - ! Parse additional arguments (simple version - only support basic flags) - env_opts = '' - file_opts = '' - net_opt = '' - artifacts = .false. - - ! Build curl command with HMAC auth (use bash to compute signature) + ! Build curl command with HMAC auth write(full_cmd, '(30A)') & 'TS=$(date +%s); ', & 'BODY=$(jq -Rs ''{language: "', trim(language), '", code: .}'' < "', trim(fname), '"); ', & @@ -183,7 +993,6 @@ contains 'sed "s/^/\x1b[31m/" | sed "s/$/\x1b[0m/" >&2; ', & 'rm -f /tmp/unsandbox_resp.json' - ! Execute command call execute_command_line(trim(full_cmd), wait=.true., exitstat=stat) if (stat /= 0) then write(0, '(A)') 'Error: Request failed' @@ -226,33 +1035,21 @@ contains if (len_trim(arg) > 0) then if (arg(1:1) == '-') then write(0, '(A,A)') 'Unknown option: ', trim(arg) - write(0, '(A)') 'Usage: un.f90 session [options]' + write(0, '(A)') 'Usage: ./un session [options]' stop 1 end if end if end if end do - ! Get API keys (try new format first, fall back to old) - call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) - if (stat == 0 .and. len_trim(public_key) > 0) then - call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) - if (stat /= 0 .or. len_trim(secret_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set' - stop 1 - end if - else - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set' - stop 1 - end if - public_key = api_key - secret_key = api_key + ! Get API keys + call get_credentials(public_key, secret_key, stat) + if (stat /= 0) then + write(0, '(A)') 'Error: No credentials found' + stop 1 end if if (list_mode) then - ! List sessions - GET request with empty body write(full_cmd, '(20A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:GET:/sessions:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & @@ -264,7 +1061,6 @@ contains '2>/dev/null || echo "No active sessions"' call execute_command_line(trim(full_cmd), wait=.true.) else if (kill_mode .and. len_trim(session_id) > 0) then - ! Kill session - DELETE request with empty body write(full_cmd, '(20A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:DELETE:/sessions/', trim(session_id), ':" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & @@ -276,7 +1072,6 @@ contains 'echo -e "\x1b[32mSession terminated: ', trim(session_id), '\x1b[0m"' call execute_command_line(trim(full_cmd), wait=.true.) else - ! Create session with optional input_files if (len_trim(input_files) > 0) then write(full_cmd, '(30A)') & 'INPUT_FILES=""; ', & @@ -341,7 +1136,6 @@ contains if (trim(arg) == '-l' .or. trim(arg) == '--list') then list_mode = .true. else if (trim(arg) == 'env') then - ! service env if (i+2 <= command_argument_count()) then call get_command_argument(i+1, env_action) call get_command_argument(i+2, env_target) @@ -419,13 +1213,7 @@ contains call get_command_argument(i+1, service_id) i = i + 1 end if - else if (trim(arg) == '--vcpu') then - if (i+1 <= command_argument_count()) then - call get_command_argument(i+1, arg) - read(arg, *) resize_vcpu - i = i + 1 - end if - else if (trim(arg) == '-v' .and. operation == 'resize') then + else if (trim(arg) == '--vcpu' .or. trim(arg) == '-v') then if (i+1 <= command_argument_count()) then call get_command_argument(i+1, arg) read(arg, *) resize_vcpu @@ -447,22 +1235,11 @@ contains i = i + 1 end do - ! Get API keys (try new format first, fall back to old) - call get_environment_variable('UNSANDBOX_PUBLIC_KEY', public_key, status=stat) - if (stat == 0 .and. len_trim(public_key) > 0) then - call get_environment_variable('UNSANDBOX_SECRET_KEY', secret_key, status=stat) - if (stat /= 0 .or. len_trim(secret_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_SECRET_KEY not set' - stop 1 - end if - else - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set' - stop 1 - end if - public_key = api_key - secret_key = api_key + ! Get API keys + call get_credentials(public_key, secret_key, stat) + if (stat /= 0) then + write(0, '(A)') 'Error: No credentials found' + stop 1 end if ! Handle env subcommand @@ -479,7 +1256,6 @@ contains call execute_command_line(trim(full_cmd), wait=.true.) return else if (trim(env_action) == 'set') then - ! Build env content from -e flags and --env-file write(full_cmd, '(50A)') & 'ENV_CONTENT=""; ', & 'ENV_LINES="', trim(svc_envs), '"; ', & @@ -531,13 +1307,12 @@ contains return else write(0, '(A,A)') 'Error: Unknown env action: ', trim(env_action) - write(0, '(A)') 'Usage: un.f90 service env ' + write(0, '(A)') 'Usage: ./un service env ' stop 1 end if end if if (list_mode) then - ! List services write(full_cmd, '(20A)') & 'TS=$(date +%s); ', & 'SIG=$(echo -n "$TS:GET:/services:" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & @@ -643,86 +1418,40 @@ contains 'else echo -e "\x1b[31mError: Failed to fetch bootstrap\x1b[0m" >&2; exit 1; fi' call execute_command_line(trim(full_cmd), wait=.true.) else if (trim(operation) == 'create' .and. len_trim(service_name) > 0) then - ! Create service with optional input_files and auto-vault - if (len_trim(input_files) > 0) then - write(full_cmd, '(60A)') & - 'INPUT_FILES=""; ', & - 'IFS='','' read -ra FILES <<< "', trim(input_files), '"; ', & - 'for f in "${FILES[@]}"; do ', & - 'b64=$(base64 -w0 "$f" 2>/dev/null || base64 "$f"); ', & - 'name=$(basename "$f"); ', & - 'if [ -n "$INPUT_FILES" ]; then INPUT_FILES="$INPUT_FILES,"; fi; ', & - 'INPUT_FILES="$INPUT_FILES{\"filename\":\"$name\",\"content\":\"$b64\"}"; ', & - 'done; ', & - 'BODY=''{"name":"', trim(service_name), '","input_files":[''"$INPUT_FILES"'']}''; ', & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', & - '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" ', & - '-d "$BODY"); ', & - 'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', & - 'if [ -n "$SVC_ID" ]; then ', & - 'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', & - 'ENV_CONTENT=""; ', & - 'ENV_LINES="', trim(svc_envs), '"; ', & - 'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', & - 'ENV_FILE="', trim(svc_env_file), '"; ', & - 'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', & - 'while IFS= read -r line || [ -n "$line" ]; do ', & - 'case "$line" in "#"*|"") continue ;; esac; ', & - 'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', & - 'ENV_CONTENT="$ENV_CONTENT$line"; ', & - 'done < "$ENV_FILE"; fi; ', & - 'if [ -n "$ENV_CONTENT" ]; then ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" ', & - '-H "X-Signature: $SIG2" ', & - '-H "Content-Type: text/plain" ', & - '--data-binary "$ENV_CONTENT" >/dev/null && ', & - 'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', & - 'else echo "$RESP" | jq .; fi' - else - write(full_cmd, '(60A)') & - 'BODY=''{"name":"', trim(service_name), '"}''; ', & - 'TS=$(date +%s); ', & - 'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', & - '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS" ', & - '-H "X-Signature: $SIG" ', & - '-d "$BODY"); ', & - 'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', & - 'if [ -n "$SVC_ID" ]; then ', & - 'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', & - 'ENV_CONTENT=""; ', & - 'ENV_LINES="', trim(svc_envs), '"; ', & - 'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', & - 'ENV_FILE="', trim(svc_env_file), '"; ', & - 'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', & - 'while IFS= read -r line || [ -n "$line" ]; do ', & - 'case "$line" in "#"*|"") continue ;; esac; ', & - 'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', & - 'ENV_CONTENT="$ENV_CONTENT$line"; ', & - 'done < "$ENV_FILE"; fi; ', & - 'if [ -n "$ENV_CONTENT" ]; then ', & - 'TS2=$(date +%s); ', & - 'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & - 'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', & - '-H "Authorization: Bearer ', trim(public_key), '" ', & - '-H "X-Timestamp: $TS2" ', & - '-H "X-Signature: $SIG2" ', & - '-H "Content-Type: text/plain" ', & - '--data-binary "$ENV_CONTENT" >/dev/null && ', & - 'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', & - 'else echo "$RESP" | jq .; fi' - end if + write(full_cmd, '(60A)') & + 'BODY=''{"name":"', trim(service_name), '"}''; ', & + 'TS=$(date +%s); ', & + 'SIG=$(echo -n "$TS:POST:/services:$BODY" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'RESP=$(curl -s -X POST https://api.unsandbox.com/services ', & + '-H "Content-Type: application/json" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS" ', & + '-H "X-Signature: $SIG" ', & + '-d "$BODY"); ', & + 'SVC_ID=$(echo "$RESP" | jq -r ".id // empty"); ', & + 'if [ -n "$SVC_ID" ]; then ', & + 'echo -e "\x1b[32m$SVC_ID created\x1b[0m"; ', & + 'ENV_CONTENT=""; ', & + 'ENV_LINES="', trim(svc_envs), '"; ', & + 'if [ -n "$ENV_LINES" ]; then ENV_CONTENT="$ENV_LINES"; fi; ', & + 'ENV_FILE="', trim(svc_env_file), '"; ', & + 'if [ -n "$ENV_FILE" ] && [ -f "$ENV_FILE" ]; then ', & + 'while IFS= read -r line || [ -n "$line" ]; do ', & + 'case "$line" in "#"*|"") continue ;; esac; ', & + 'if [ -n "$ENV_CONTENT" ]; then ENV_CONTENT="$ENV_CONTENT', char(10), '"; fi; ', & + 'ENV_CONTENT="$ENV_CONTENT$line"; ', & + 'done < "$ENV_FILE"; fi; ', & + 'if [ -n "$ENV_CONTENT" ]; then ', & + 'TS2=$(date +%s); ', & + 'SIG2=$(echo -n "$TS2:PUT:/services/$SVC_ID/env:$ENV_CONTENT" | openssl dgst -sha256 -hmac "', trim(secret_key), '" | cut -d" " -f2); ', & + 'curl -s -X PUT "https://api.unsandbox.com/services/$SVC_ID/env" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & + '-H "X-Timestamp: $TS2" ', & + '-H "X-Signature: $SIG2" ', & + '-H "Content-Type: text/plain" ', & + '--data-binary "$ENV_CONTENT" >/dev/null && ', & + 'echo -e "\x1b[32mVault configured\x1b[0m"; fi; ', & + 'else echo "$RESP" | jq .; fi' call execute_command_line(trim(full_cmd), wait=.true.) else write(0, '(A)') 'Error: Use --list, --info, --logs, --freeze, --unfreeze, --destroy, --dump-bootstrap, --name, or env' @@ -733,6 +1462,7 @@ contains subroutine handle_key() character(len=4096) :: full_cmd character(len=256) :: arg + character(len=1024) :: public_key, secret_key integer :: i, stat logical :: extend_mode character(len=32) :: portal_base @@ -749,18 +1479,17 @@ contains end do ! Get API key - call get_environment_variable('UNSANDBOX_API_KEY', api_key, status=stat) - if (stat /= 0 .or. len_trim(api_key) == 0) then - write(0, '(A)') 'Error: UNSANDBOX_API_KEY not set' + call get_credentials(public_key, secret_key, stat) + if (stat /= 0) then + write(0, '(A)') 'Error: No credentials found' stop 1 end if if (extend_mode) then - ! Validate and extend write(full_cmd, '(30A)') & 'resp=$(curl -s -X POST ', trim(portal_base), '/keys/validate ', & '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(api_key), '" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & '-d "{}"); ', & 'status=$(echo "$resp" | jq -r ".status // empty"); ', & 'public_key=$(echo "$resp" | jq -r ".public_key // empty"); ', & @@ -794,11 +1523,10 @@ contains 'sensible-browser "', trim(portal_base), '/keys/extend?pk=$public_key" 2>/dev/null &; ', & 'else echo -e "\x1b[31mInvalid\x1b[0m"; fi' else - ! Validate only write(full_cmd, '(30A)') & 'resp=$(curl -s -X POST ', trim(portal_base), '/keys/validate ', & '-H "Content-Type: application/json" ', & - '-H "Authorization: Bearer ', trim(api_key), '" ', & + '-H "Authorization: Bearer ', trim(public_key), '" ', & '-d "{}"); ', & 'status=$(echo "$resp" | jq -r ".status // empty"); ', & 'public_key=$(echo "$resp" | jq -r ".public_key // empty"); ', & diff --git a/un.go b/un.go index a223ace..58bffad 100644 --- a/un.go +++ b/un.go @@ -33,31 +33,46 @@ // https://www.timehexon.com // https://www.foxhop.net // https://www.unturf.com/software - - -// UN CLI - Go Implementation -// Compile: go build -o un_go un.go -// Usage: -// un.go script.py -// un.go -e KEY=VALUE -f data.txt script.py -// un.go session --list -// un.go service --name web --ports 8080 --bootstrap "python -m http.server" +// +// unsandbox SDK for Go - Execute code in secure sandboxes +// https://unsandbox.com | https://api.unsandbox.com/openapi +// +// Library Usage: +// // Change "package main" to "package unsandbox" to use as library +// import "unsandbox" +// result, err := unsandbox.Execute("python", `print("Hello")`, nil) +// job, err := unsandbox.ExecuteAsync("python", code, nil) +// result, err := unsandbox.Wait(job.JobID, nil) +// +// CLI Usage: +// go run un.go script.py +// go run un.go -s python 'print("Hello")' +// go run un.go session --shell python3 +// +// Authentication (in priority order): +// 1. Function arguments: Execute(..., &Options{PublicKey: "...", SecretKey: "..."}) +// 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +// 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) package main import ( + "bufio" "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" + "errors" "flag" "fmt" "io" "net/http" + "net/url" "os" "os/exec" + "os/user" "path/filepath" "runtime" "strconv" @@ -65,17 +80,37 @@ import ( "time" ) +// ============================================================================ +// Configuration +// ============================================================================ + const ( - APIBase = "https://api.unsandbox.com" - PortalBase = "https://unsandbox.com" - Blue = "\033[34m" - Red = "\033[31m" - Green = "\033[32m" - Yellow = "\033[33m" - Reset = "\033[0m" + // APIBase is the base URL for the unsandbox API + APIBase = "https://api.unsandbox.com" + // PortalBase is the base URL for the unsandbox portal + PortalBase = "https://unsandbox.com" + // DefaultTimeout is the default HTTP request timeout in seconds + DefaultTimeout = 300 + // DefaultTTL is the default execution timeout in seconds + DefaultTTL = 60 + // Version is the SDK version + Version = "2.0.0" ) -var extMap = map[string]string{ +// ANSI color codes for terminal output +const ( + Blue = "\033[34m" + Red = "\033[31m" + Green = "\033[32m" + Yellow = "\033[33m" + Reset = "\033[0m" +) + +// PollDelays defines the exponential backoff delays in milliseconds +var PollDelays = []int{300, 450, 700, 900, 650, 1600, 2000} + +// ExtMap maps file extensions to language names +var ExtMap = map[string]string{ ".py": "python", ".js": "javascript", ".ts": "typescript", ".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua", ".sh": "bash", ".go": "go", ".rs": "rust", ".c": "c", @@ -83,14 +118,988 @@ var extMap = map[string]string{ ".java": "java", ".kt": "kotlin", ".cs": "csharp", ".fs": "fsharp", ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure", ".scm": "scheme", ".lisp": "commonlisp", ".erl": "erlang", ".ex": "elixir", ".exs": "elixir", - ".jl": "julia", ".r": "r", ".cr": "crystal", + ".jl": "julia", ".r": "r", ".R": "r", ".cr": "crystal", ".d": "d", ".nim": "nim", ".zig": "zig", ".v": "v", ".dart": "dart", ".groovy": "groovy", ".scala": "scala", ".f90": "fortran", ".f95": "fortran", ".cob": "cobol", ".pro": "prolog", ".forth": "forth", ".4th": "forth", - ".tcl": "tcl", ".raku": "raku", ".m": "objc", + ".tcl": "tcl", ".raku": "raku", ".m": "objc", ".awk": "awk", } +// ============================================================================ +// Errors +// ============================================================================ + +var ( + // ErrNoCredentials is returned when no API credentials are found + ErrNoCredentials = errors.New("no credentials found: set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, or create ~/.unsandbox/accounts.csv, or pass credentials to function") + // ErrAuthenticationFailed is returned when authentication fails + ErrAuthenticationFailed = errors.New("authentication failed") + // ErrTimestampExpired is returned when the request timestamp is expired + ErrTimestampExpired = errors.New("request timestamp expired: your system clock may be out of sync") + // ErrTimeout is returned when a job times out + ErrTimeout = errors.New("job timed out") + // ErrMaxPollsExceeded is returned when max polling attempts are exceeded + ErrMaxPollsExceeded = errors.New("max polls exceeded") +) + +// UnsandboxError represents an API error with status code and response +type UnsandboxError struct { + Message string + StatusCode int + Response string +} + +func (e *UnsandboxError) Error() string { + return e.Message +} + +// ExecutionError represents a code execution failure +type ExecutionError struct { + Message string + ExitCode int + Stderr string +} + +func (e *ExecutionError) Error() string { + return e.Message +} + +// ============================================================================ +// Types +// ============================================================================ + +// Options contains optional parameters for API requests +type Options struct { + PublicKey string + SecretKey string + AccountIndex int + Env map[string]string + InputFiles []InputFile + NetworkMode string // "zerotrust" or "semitrusted" + TTL int // Execution timeout in seconds (1-900) + VCPU int // Virtual CPUs (1-8) + ReturnArtifact bool + ReturnWasm bool + Timeout int // HTTP request timeout in seconds + MaxPolls int // Maximum polling attempts for wait() +} + +// InputFile represents a file to be sent with the execution request +type InputFile struct { + Filename string + Content string + ContentBase64 string +} + +// ExecuteResult represents the result of a code execution +type ExecuteResult struct { + Success bool `json:"success"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + Language string `json:"language"` + JobID string `json:"job_id"` + TotalTimeMs int `json:"total_time_ms"` + NetworkMode string `json:"network_mode"` + Artifacts []Artifact `json:"artifacts,omitempty"` + Error string `json:"error,omitempty"` +} + +// JobResult represents an async job status +type JobResult struct { + JobID string `json:"job_id"` + Status string `json:"status"` + DetectedLanguage string `json:"detected_language,omitempty"` + Result *ExecuteResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` + SubmittedAt string `json:"submitted_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` +} + +// Artifact represents a build artifact +type Artifact struct { + Filename string `json:"filename"` + ContentBase64 string `json:"content_base64"` + Size int `json:"size,omitempty"` +} + +// LanguagesResult represents the result of languages() call +type LanguagesResult struct { + Languages []string `json:"languages"` + Count int `json:"count"` + Aliases map[string]string `json:"aliases,omitempty"` +} + +// ImageResult represents the result of image generation +type ImageResult struct { + Images []string `json:"images"` + CreatedAt string `json:"created_at,omitempty"` +} + +// Credentials holds API key pair +type Credentials struct { + PublicKey string + SecretKey string +} + +// ============================================================================ +// HMAC Authentication +// ============================================================================ + +// SignRequest generates an HMAC-SHA256 signature for an API request. +// Signature = HMAC-SHA256(secretKey, "timestamp:METHOD:path:body") +func SignRequest(secretKey string, timestamp int64, method, path, body string) string { + message := fmt.Sprintf("%d:%s:%s:%s", timestamp, method, path, body) + h := hmac.New(sha256.New, []byte(secretKey)) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} + +// GetCredentials retrieves API credentials in priority order: +// 1. Function arguments (publicKey, secretKey) +// 2. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +// 3. Config file ~/.unsandbox/accounts.csv +func GetCredentials(publicKey, secretKey string, accountIndex int) (*Credentials, error) { + // Priority 1: Function arguments + if publicKey != "" && secretKey != "" { + return &Credentials{PublicKey: publicKey, SecretKey: secretKey}, nil + } + + // Priority 2: Environment variables + envPk := os.Getenv("UNSANDBOX_PUBLIC_KEY") + envSk := os.Getenv("UNSANDBOX_SECRET_KEY") + if envPk != "" && envSk != "" { + return &Credentials{PublicKey: envPk, SecretKey: envSk}, nil + } + + // Priority 3: Config file + usr, err := user.Current() + if err == nil { + accountsPath := filepath.Join(usr.HomeDir, ".unsandbox", "accounts.csv") + if data, err := os.ReadFile(accountsPath); err == nil { + var validAccounts []Credentials + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, ",", 2) + if len(parts) == 2 { + pk := strings.TrimSpace(parts[0]) + sk := strings.TrimSpace(parts[1]) + if strings.HasPrefix(pk, "unsb-pk-") && strings.HasPrefix(sk, "unsb-sk-") { + validAccounts = append(validAccounts, Credentials{PublicKey: pk, SecretKey: sk}) + } + } + } + if len(validAccounts) > accountIndex { + return &validAccounts[accountIndex], nil + } + } + } + + return nil, ErrNoCredentials +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +// apiRequest makes an authenticated API request with HMAC signature +func apiRequest(endpoint, method string, data interface{}, contentType string, opts *Options) (map[string]interface{}, error) { + if opts == nil { + opts = &Options{} + } + + creds, err := GetCredentials(opts.PublicKey, opts.SecretKey, opts.AccountIndex) + if err != nil { + return nil, err + } + + urlStr := APIBase + endpoint + var reqBody io.Reader + bodyStr := "" + + if data != nil { + switch v := data.(type) { + case string: + bodyStr = v + reqBody = strings.NewReader(v) + default: + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("error marshaling JSON: %w", err) + } + bodyStr = string(jsonData) + reqBody = bytes.NewBuffer(jsonData) + } + } + + req, err := http.NewRequest(method, urlStr, reqBody) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + // HMAC authentication + timestamp := time.Now().Unix() + signature := SignRequest(creds.SecretKey, timestamp, method, endpoint, bodyStr) + + req.Header.Set("Authorization", "Bearer "+creds.PublicKey) + req.Header.Set("X-Timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Set("X-Signature", signature) + if contentType == "" { + contentType = "application/json" + } + req.Header.Set("Content-Type", contentType) + + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response: %w", err) + } + + if resp.StatusCode >= 400 { + if resp.StatusCode == 401 && strings.Contains(strings.ToLower(string(body)), "timestamp") { + return nil, ErrTimestampExpired + } + if resp.StatusCode == 401 { + return nil, &UnsandboxError{ + Message: fmt.Sprintf("authentication failed: %s", string(body)), + StatusCode: resp.StatusCode, + Response: string(body), + } + } + return nil, &UnsandboxError{ + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(body)), + StatusCode: resp.StatusCode, + Response: string(body), + } + } + + var result map[string]interface{} + if len(body) > 0 { + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("error parsing response: %w", err) + } + } + + return result, nil +} + +// ============================================================================ +// Core Execution Functions +// ============================================================================ + +// Execute runs code synchronously and returns results. +// +// Parameters: +// - language: Programming language (python, javascript, go, rust, etc.) +// - code: Source code to execute +// - opts: Optional parameters (env, inputFiles, networkMode, ttl, vcpu, etc.) +// +// Returns ExecuteResult with stdout, stderr, exit_code, etc. +// +// Example: +// result, err := un.Execute("python", `print("Hello World")`, nil) +// if err != nil { log.Fatal(err) } +// fmt.Println(result.Stdout) +func Execute(language, code string, opts *Options) (*ExecuteResult, error) { + if opts == nil { + opts = &Options{} + } + + payload := map[string]interface{}{ + "language": language, + "code": code, + "network_mode": getNetworkMode(opts.NetworkMode), + "ttl": getTTL(opts.TTL), + "vcpu": getVCPU(opts.VCPU), + } + + if opts.Env != nil && len(opts.Env) > 0 { + payload["env"] = opts.Env + } + + if len(opts.InputFiles) > 0 { + payload["input_files"] = processInputFiles(opts.InputFiles) + } + + if opts.ReturnArtifact { + payload["return_artifact"] = true + } + if opts.ReturnWasm { + payload["return_wasm_artifact"] = true + } + + result, err := apiRequest("/execute", "POST", payload, "application/json", opts) + if err != nil { + return nil, err + } + + return parseExecuteResult(result), nil +} + +// ExecuteAsync executes code asynchronously and returns a job ID for polling. +// +// Parameters: +// - language: Programming language +// - code: Source code to execute +// - opts: Optional parameters +// +// Returns JobResult with job_id and status ("pending") +// +// Example: +// job, err := un.ExecuteAsync("python", longRunningCode, nil) +// fmt.Printf("Job submitted: %s\n", job.JobID) +// result, err := un.Wait(job.JobID, nil) +func ExecuteAsync(language, code string, opts *Options) (*JobResult, error) { + if opts == nil { + opts = &Options{} + } + + payload := map[string]interface{}{ + "language": language, + "code": code, + "network_mode": getNetworkMode(opts.NetworkMode), + "ttl": getTTL(opts.TTL), + "vcpu": getVCPU(opts.VCPU), + } + + if opts.Env != nil && len(opts.Env) > 0 { + payload["env"] = opts.Env + } + + if len(opts.InputFiles) > 0 { + payload["input_files"] = processInputFiles(opts.InputFiles) + } + + if opts.ReturnArtifact { + payload["return_artifact"] = true + } + if opts.ReturnWasm { + payload["return_wasm_artifact"] = true + } + + result, err := apiRequest("/execute/async", "POST", payload, "application/json", opts) + if err != nil { + return nil, err + } + + return parseJobResult(result), nil +} + +// Run executes code with automatic language detection from shebang. +// +// Parameters: +// - code: Source code with shebang (e.g., #!/usr/bin/env python3) +// - opts: Optional parameters +// +// Returns ExecuteResult with detected_language, stdout, stderr, etc. +// +// Example: +// code := "#!/usr/bin/env python3\nprint('Auto-detected!')" +// result, err := un.Run(code, nil) +// fmt.Println(result.Language) // "python" +func Run(code string, opts *Options) (*ExecuteResult, error) { + if opts == nil { + opts = &Options{} + } + + params := url.Values{} + params.Set("ttl", strconv.Itoa(getTTL(opts.TTL))) + params.Set("network_mode", getNetworkMode(opts.NetworkMode)) + + if opts.Env != nil && len(opts.Env) > 0 { + envJSON, _ := json.Marshal(opts.Env) + params.Set("env", string(envJSON)) + } + + endpoint := "/run?" + params.Encode() + result, err := apiRequest(endpoint, "POST", code, "text/plain", opts) + if err != nil { + return nil, err + } + + return parseExecuteResult(result), nil +} + +// RunAsync executes code asynchronously with automatic language detection. +// +// Parameters: +// - code: Source code with shebang +// - opts: Optional parameters +// +// Returns JobResult with job_id, detected_language, status ("pending") +func RunAsync(code string, opts *Options) (*JobResult, error) { + if opts == nil { + opts = &Options{} + } + + params := url.Values{} + params.Set("ttl", strconv.Itoa(getTTL(opts.TTL))) + params.Set("network_mode", getNetworkMode(opts.NetworkMode)) + + if opts.Env != nil && len(opts.Env) > 0 { + envJSON, _ := json.Marshal(opts.Env) + params.Set("env", string(envJSON)) + } + + endpoint := "/run/async?" + params.Encode() + result, err := apiRequest(endpoint, "POST", code, "text/plain", opts) + if err != nil { + return nil, err + } + + return parseJobResult(result), nil +} + +// ============================================================================ +// Job Management +// ============================================================================ + +// GetJob retrieves job status and results. +// +// Parameters: +// - jobID: Job ID from ExecuteAsync or RunAsync +// - opts: Optional parameters (credentials) +// +// Returns JobResult with status: pending, running, completed, failed, timeout, cancelled +func GetJob(jobID string, opts *Options) (*JobResult, error) { + result, err := apiRequest("/jobs/"+jobID, "GET", nil, "", opts) + if err != nil { + return nil, err + } + return parseJobResult(result), nil +} + +// Wait polls for job completion with exponential backoff. +// +// Parameters: +// - jobID: Job ID from ExecuteAsync or RunAsync +// - opts: Optional parameters (MaxPolls defaults to 100) +// +// Returns final JobResult when job completes +// +// Example: +// job, _ := un.ExecuteAsync("python", code, nil) +// result, err := un.Wait(job.JobID, nil) +// fmt.Println(result.Result.Stdout) +func Wait(jobID string, opts *Options) (*JobResult, error) { + if opts == nil { + opts = &Options{} + } + + maxPolls := opts.MaxPolls + if maxPolls <= 0 { + maxPolls = 100 + } + + terminalStates := map[string]bool{ + "completed": true, + "failed": true, + "timeout": true, + "cancelled": true, + } + + for i := 0; i < maxPolls; i++ { + // Exponential backoff delay + delayIdx := i + if delayIdx >= len(PollDelays) { + delayIdx = len(PollDelays) - 1 + } + time.Sleep(time.Duration(PollDelays[delayIdx]) * time.Millisecond) + + result, err := GetJob(jobID, opts) + if err != nil { + return nil, err + } + + if terminalStates[result.Status] { + if result.Status == "failed" { + return nil, &ExecutionError{ + Message: fmt.Sprintf("job failed: %s", result.Error), + ExitCode: -1, + Stderr: result.Error, + } + } + if result.Status == "timeout" { + return nil, fmt.Errorf("%w: %s", ErrTimeout, jobID) + } + return result, nil + } + } + + return nil, fmt.Errorf("%w: job %s after %d polls", ErrMaxPollsExceeded, jobID, maxPolls) +} + +// CancelJob cancels a running job. +// +// Returns partial output and artifacts collected before cancellation. +func CancelJob(jobID string, opts *Options) (*JobResult, error) { + result, err := apiRequest("/jobs/"+jobID, "DELETE", nil, "", opts) + if err != nil { + return nil, err + } + return parseJobResult(result), nil +} + +// ListJobs returns all active jobs for this API key. +// +// Returns slice of JobResult with job_id, language, status, submitted_at +func ListJobs(opts *Options) ([]JobResult, error) { + result, err := apiRequest("/jobs", "GET", nil, "", opts) + if err != nil { + return nil, err + } + + var jobs []JobResult + if jobsRaw, ok := result["jobs"].([]interface{}); ok { + for _, j := range jobsRaw { + if jMap, ok := j.(map[string]interface{}); ok { + jobs = append(jobs, *parseJobResult(jMap)) + } + } + } + return jobs, nil +} + +// ============================================================================ +// Image Generation +// ============================================================================ + +// ImageOptions contains options for image generation +type ImageOptions struct { + PublicKey string + SecretKey string + Model string + Size string // e.g., "1024x1024", "512x512" + Quality string // "standard" or "hd" + N int // Number of images to generate +} + +// Image generates images from a text prompt. +// +// Parameters: +// - prompt: Text description of the image to generate +// - opts: Optional parameters (model, size, quality, n) +// +// Example: +// result, err := un.Image("A sunset over mountains", nil) +// fmt.Println(result.Images[0]) +func Image(prompt string, opts *ImageOptions) (*ImageResult, error) { + if opts == nil { + opts = &ImageOptions{} + } + + payload := map[string]interface{}{ + "prompt": prompt, + } + + size := opts.Size + if size == "" { + size = "1024x1024" + } + payload["size"] = size + + quality := opts.Quality + if quality == "" { + quality = "standard" + } + payload["quality"] = quality + + n := opts.N + if n <= 0 { + n = 1 + } + payload["n"] = n + + if opts.Model != "" { + payload["model"] = opts.Model + } + + apiOpts := &Options{ + PublicKey: opts.PublicKey, + SecretKey: opts.SecretKey, + } + + result, err := apiRequest("/image", "POST", payload, "application/json", apiOpts) + if err != nil { + return nil, err + } + + imgResult := &ImageResult{} + if images, ok := result["images"].([]interface{}); ok { + for _, img := range images { + if imgStr, ok := img.(string); ok { + imgResult.Images = append(imgResult.Images, imgStr) + } + } + } + if createdAt, ok := result["created_at"].(string); ok { + imgResult.CreatedAt = createdAt + } + + return imgResult, nil +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +// Languages returns the list of supported programming languages. +// Results are cached in ~/.unsandbox/languages.json for 1 hour. +// +// Parameters: +// - forceRefresh: Bypass cache and fetch fresh data +// - opts: Optional parameters (credentials) +func Languages(forceRefresh bool, opts *Options) (*LanguagesResult, error) { + usr, err := user.Current() + if err == nil && !forceRefresh { + cachePath := filepath.Join(usr.HomeDir, ".unsandbox", "languages.json") + if info, err := os.Stat(cachePath); err == nil { + cacheMaxAge := time.Hour + if time.Since(info.ModTime()) < cacheMaxAge { + if data, err := os.ReadFile(cachePath); err == nil { + var cached LanguagesResult + if json.Unmarshal(data, &cached) == nil { + return &cached, nil + } + } + } + } + } + + result, err := apiRequest("/languages", "GET", nil, "", opts) + if err != nil { + return nil, err + } + + langResult := &LanguagesResult{} + if langs, ok := result["languages"].([]interface{}); ok { + for _, l := range langs { + if lStr, ok := l.(string); ok { + langResult.Languages = append(langResult.Languages, lStr) + } + } + } + if count, ok := result["count"].(float64); ok { + langResult.Count = int(count) + } + if aliases, ok := result["aliases"].(map[string]interface{}); ok { + langResult.Aliases = make(map[string]string) + for k, v := range aliases { + if vStr, ok := v.(string); ok { + langResult.Aliases[k] = vStr + } + } + } + + // Save to cache + if usr != nil { + cacheDir := filepath.Join(usr.HomeDir, ".unsandbox") + os.MkdirAll(cacheDir, 0755) + cachePath := filepath.Join(cacheDir, "languages.json") + if data, err := json.Marshal(langResult); err == nil { + os.WriteFile(cachePath, data, 0644) + } + } + + return langResult, nil +} + +// DetectLanguage detects programming language from file extension or shebang. +// Returns language name or empty string if undetected. +func DetectLanguage(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if lang, ok := ExtMap[ext]; ok { + return lang + } + + // Try shebang + data, err := os.ReadFile(filename) + if err == nil && len(data) > 0 { + firstLine := strings.Split(string(data), "\n")[0] + if strings.HasPrefix(firstLine, "#!") { + if strings.Contains(firstLine, "python") { + return "python" + } + if strings.Contains(firstLine, "node") { + return "javascript" + } + if strings.Contains(firstLine, "ruby") { + return "ruby" + } + if strings.Contains(firstLine, "perl") { + return "perl" + } + if strings.Contains(firstLine, "bash") || strings.Contains(firstLine, "/sh") { + return "bash" + } + if strings.Contains(firstLine, "lua") { + return "lua" + } + if strings.Contains(firstLine, "php") { + return "php" + } + } + } + + return "" +} + +// ============================================================================ +// Client +// ============================================================================ + +// Client is an API client with stored credentials. +// +// Example: +// client, err := un.NewClient("unsb-pk-...", "unsb-sk-...") +// result, err := client.Execute("python", `print("Hello")`, nil) +// +// // Or load from environment/config automatically: +// client, err := un.NewClientFromEnv() +// result, err := client.Execute("python", code, nil) +type Client struct { + PublicKey string + SecretKey string +} + +// NewClient creates a new Client with explicit credentials. +func NewClient(publicKey, secretKey string) (*Client, error) { + if publicKey == "" || secretKey == "" { + return nil, ErrNoCredentials + } + return &Client{ + PublicKey: publicKey, + SecretKey: secretKey, + }, nil +} + +// NewClientFromEnv creates a new Client loading credentials from environment or config. +func NewClientFromEnv() (*Client, error) { + creds, err := GetCredentials("", "", 0) + if err != nil { + return nil, err + } + return &Client{ + PublicKey: creds.PublicKey, + SecretKey: creds.SecretKey, + }, nil +} + +// NewClientFromConfig creates a new Client loading credentials from config file at specified index. +func NewClientFromConfig(accountIndex int) (*Client, error) { + creds, err := GetCredentials("", "", accountIndex) + if err != nil { + return nil, err + } + return &Client{ + PublicKey: creds.PublicKey, + SecretKey: creds.SecretKey, + }, nil +} + +func (c *Client) opts(opts *Options) *Options { + if opts == nil { + opts = &Options{} + } + opts.PublicKey = c.PublicKey + opts.SecretKey = c.SecretKey + return opts +} + +// Execute runs code synchronously. See package-level Execute() for details. +func (c *Client) Execute(language, code string, opts *Options) (*ExecuteResult, error) { + return Execute(language, code, c.opts(opts)) +} + +// ExecuteAsync executes code asynchronously. See package-level ExecuteAsync() for details. +func (c *Client) ExecuteAsync(language, code string, opts *Options) (*JobResult, error) { + return ExecuteAsync(language, code, c.opts(opts)) +} + +// Run executes with auto-detect. See package-level Run() for details. +func (c *Client) Run(code string, opts *Options) (*ExecuteResult, error) { + return Run(code, c.opts(opts)) +} + +// RunAsync executes async with auto-detect. See package-level RunAsync() for details. +func (c *Client) RunAsync(code string, opts *Options) (*JobResult, error) { + return RunAsync(code, c.opts(opts)) +} + +// GetJob retrieves job status. See package-level GetJob() for details. +func (c *Client) GetJob(jobID string) (*JobResult, error) { + return GetJob(jobID, c.opts(nil)) +} + +// Wait polls for job completion. See package-level Wait() for details. +func (c *Client) Wait(jobID string, opts *Options) (*JobResult, error) { + return Wait(jobID, c.opts(opts)) +} + +// CancelJob cancels a job. See package-level CancelJob() for details. +func (c *Client) CancelJob(jobID string) (*JobResult, error) { + return CancelJob(jobID, c.opts(nil)) +} + +// ListJobs lists active jobs. See package-level ListJobs() for details. +func (c *Client) ListJobs() ([]JobResult, error) { + return ListJobs(c.opts(nil)) +} + +// Image generates an image. See package-level Image() for details. +func (c *Client) Image(prompt string, opts *ImageOptions) (*ImageResult, error) { + if opts == nil { + opts = &ImageOptions{} + } + opts.PublicKey = c.PublicKey + opts.SecretKey = c.SecretKey + return Image(prompt, opts) +} + +// Languages returns supported languages. See package-level Languages() for details. +func (c *Client) Languages(forceRefresh bool) (*LanguagesResult, error) { + return Languages(forceRefresh, c.opts(nil)) +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +func getNetworkMode(mode string) string { + if mode == "" { + return "zerotrust" + } + return mode +} + +func getTTL(ttl int) int { + if ttl <= 0 { + return DefaultTTL + } + return ttl +} + +func getVCPU(vcpu int) int { + if vcpu <= 0 { + return 1 + } + return vcpu +} + +func processInputFiles(files []InputFile) []map[string]string { + result := make([]map[string]string, 0, len(files)) + for _, f := range files { + entry := map[string]string{"filename": f.Filename} + if f.ContentBase64 != "" { + entry["content_base64"] = f.ContentBase64 + } else if f.Content != "" { + entry["content_base64"] = base64.StdEncoding.EncodeToString([]byte(f.Content)) + } + result = append(result, entry) + } + return result +} + +func parseExecuteResult(m map[string]interface{}) *ExecuteResult { + r := &ExecuteResult{} + if v, ok := m["success"].(bool); ok { + r.Success = v + } + if v, ok := m["stdout"].(string); ok { + r.Stdout = v + } + if v, ok := m["stderr"].(string); ok { + r.Stderr = v + } + if v, ok := m["exit_code"].(float64); ok { + r.ExitCode = int(v) + } + if v, ok := m["language"].(string); ok { + r.Language = v + } + if v, ok := m["detected_language"].(string); ok { + r.Language = v + } + if v, ok := m["job_id"].(string); ok { + r.JobID = v + } + if v, ok := m["total_time_ms"].(float64); ok { + r.TotalTimeMs = int(v) + } + if v, ok := m["network_mode"].(string); ok { + r.NetworkMode = v + } + if v, ok := m["error"].(string); ok { + r.Error = v + } + if arts, ok := m["artifacts"].([]interface{}); ok { + for _, a := range arts { + if aMap, ok := a.(map[string]interface{}); ok { + art := Artifact{} + if fn, ok := aMap["filename"].(string); ok { + art.Filename = fn + } + if cb, ok := aMap["content_base64"].(string); ok { + art.ContentBase64 = cb + } + if sz, ok := aMap["size"].(float64); ok { + art.Size = int(sz) + } + r.Artifacts = append(r.Artifacts, art) + } + } + } + return r +} + +func parseJobResult(m map[string]interface{}) *JobResult { + r := &JobResult{} + if v, ok := m["job_id"].(string); ok { + r.JobID = v + } + if v, ok := m["status"].(string); ok { + r.Status = v + } + if v, ok := m["detected_language"].(string); ok { + r.DetectedLanguage = v + } + if v, ok := m["error"].(string); ok { + r.Error = v + } + if v, ok := m["submitted_at"].(string); ok { + r.SubmittedAt = v + } + if v, ok := m["completed_at"].(string); ok { + r.CompletedAt = v + } + if res, ok := m["result"].(map[string]interface{}); ok { + r.Result = parseExecuteResult(res) + } + return r +} + +// ============================================================================ +// CLI Interface +// ============================================================================ + +const MaxEnvContentSize = 64 * 1024 // 64KB max env vault size + type envVars []string func (e *envVars) String() string { return "" } @@ -99,49 +1108,18 @@ func (e *envVars) Set(value string) error { return nil } -type inputFiles []string +type inputFilesFlag []string -func (i *inputFiles) String() string { return "" } -func (i *inputFiles) Set(value string) error { +func (i *inputFilesFlag) String() string { return "" } +func (i *inputFilesFlag) Set(value string) error { *i = append(*i, value) return nil } -func detectLanguage(filename string) (string, error) { - ext := strings.ToLower(filepath.Ext(filename)) - if lang, ok := extMap[ext]; ok { - return lang, nil - } - - // Try shebang - data, err := os.ReadFile(filename) - if err == nil { - firstLine := strings.Split(string(data), "\n")[0] - if strings.HasPrefix(firstLine, "#!") { - if strings.Contains(firstLine, "python") { - return "python", nil - } - if strings.Contains(firstLine, "node") { - return "javascript", nil - } - if strings.Contains(firstLine, "ruby") { - return "ruby", nil - } - if strings.Contains(firstLine, "bash") || strings.Contains(firstLine, "/sh") { - return "bash", nil - } - } - } - - return "", fmt.Errorf("cannot detect language from extension") -} - -func getAPIKeys(keyArg string) (string, string) { - publicKey := os.Getenv("UNSANDBOX_PUBLIC_KEY") - secretKey := os.Getenv("UNSANDBOX_SECRET_KEY") - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if publicKey == "" || secretKey == "" { +func cliGetAPIKeys(keyArg string) (string, string) { + creds, err := GetCredentials("", "", 0) + if err != nil { + // Fall back to UNSANDBOX_API_KEY for backwards compatibility fallbackKey := keyArg if fallbackKey == "" { fallbackKey = os.Getenv("UNSANDBOX_API_KEY") @@ -150,66 +1128,16 @@ func getAPIKeys(keyArg string) (string, string) { fmt.Fprintf(os.Stderr, "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)%s\n", Red, Reset) os.Exit(1) } - // Use fallback key as both public and secret for backwards compatibility return fallbackKey, fallbackKey } - - return publicKey, secretKey + return creds.PublicKey, creds.SecretKey } -func computeHMAC(secretKey, timestamp, method, path, body string) string { - message := fmt.Sprintf("%s:%s:%s:%s", timestamp, method, path, body) - h := hmac.New(sha256.New, []byte(secretKey)) - h.Write([]byte(message)) - return hex.EncodeToString(h.Sum(nil)) -} - -func apiRequest(endpoint, method string, data map[string]interface{}, publicKey, secretKey string) map[string]interface{} { - url := APIBase + endpoint - var reqBody io.Reader - bodyStr := "" - - if data != nil { - jsonData, err := json.Marshal(data) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError marshaling JSON: %v%s\n", Red, err, Reset) - os.Exit(1) - } - bodyStr = string(jsonData) - reqBody = bytes.NewBuffer(jsonData) - } - - req, err := http.NewRequest(method, url, reqBody) +func cliApiRequest(endpoint, method string, data map[string]interface{}, publicKey, secretKey string) map[string]interface{} { + opts := &Options{PublicKey: publicKey, SecretKey: secretKey} + result, err := apiRequest(endpoint, method, data, "application/json", opts) if err != nil { - fmt.Fprintf(os.Stderr, "%sError creating request: %v%s\n", Red, err, Reset) - os.Exit(1) - } - - // HMAC authentication - timestamp := fmt.Sprintf("%d", time.Now().Unix()) - signature := computeHMAC(secretKey, timestamp, method, endpoint, bodyStr) - - req.Header.Set("Authorization", "Bearer "+publicKey) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Signature", signature) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError making request: %v%s\n", Red, err, Reset) - os.Exit(1) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError reading response: %v%s\n", Red, err, Reset) - os.Exit(1) - } - - if resp.StatusCode >= 400 { - if resp.StatusCode == 401 && strings.Contains(strings.ToLower(string(body)), "timestamp") { + if errors.Is(err, ErrTimestampExpired) { fmt.Fprintf(os.Stderr, "%sError: Request timestamp expired (must be within 5 minutes of server time)%s\n", Red, Reset) fmt.Fprintf(os.Stderr, "%sYour computer's clock may have drifted.%s\n", Yellow, Reset) fmt.Fprintf(os.Stderr, "%sCheck your system time and sync with NTP if needed:%s\n", Yellow, Reset) @@ -217,69 +1145,24 @@ func apiRequest(endpoint, method string, data map[string]interface{}, publicKey, fmt.Fprintf(os.Stderr, " macOS: sudo sntp -sS time.apple.com\n") fmt.Fprintf(os.Stderr, " Windows: w32tm /resync\n") } else { - fmt.Fprintf(os.Stderr, "%sError: HTTP %d - %s%s\n", Red, resp.StatusCode, string(body), Reset) + fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset) } os.Exit(1) } - - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - fmt.Fprintf(os.Stderr, "%sError parsing response: %v%s\n", Red, err, Reset) - os.Exit(1) - } - return result } -func apiRequestText(endpoint, method, body, publicKey, secretKey string) (map[string]interface{}, error) { - url := APIBase + endpoint - - req, err := http.NewRequest(method, url, strings.NewReader(body)) - if err != nil { - return nil, err - } - - // HMAC authentication - timestamp := fmt.Sprintf("%d", time.Now().Unix()) - signature := computeHMAC(secretKey, timestamp, method, endpoint, body) - - req.Header.Set("Authorization", "Bearer "+publicKey) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Signature", signature) - req.Header.Set("Content-Type", "text/plain") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("HTTP %d - %s", resp.StatusCode, string(respBody)) - } - - var result map[string]interface{} - if err := json.Unmarshal(respBody, &result); err != nil { - return nil, err - } - - return result, nil +func cliApiRequestText(endpoint, method, body, publicKey, secretKey string) (map[string]interface{}, error) { + opts := &Options{PublicKey: publicKey, SecretKey: secretKey} + return apiRequest(endpoint, method, body, "text/plain", opts) } // ============================================================================ // Environment Secrets Vault Functions // ============================================================================ -const MaxEnvContentSize = 64 * 1024 // 64KB max env vault size - func serviceEnvStatus(serviceID, publicKey, secretKey string) { - result := apiRequest("/services/"+serviceID+"/env", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceID+"/env", "GET", nil, publicKey, secretKey) hasVault, _ := result["has_vault"].(bool) if !hasVault { @@ -308,7 +1191,7 @@ func serviceEnvSet(serviceID, envContent, publicKey, secretKey string) bool { return false } - result, err := apiRequestText("/services/"+serviceID+"/env", "PUT", envContent, publicKey, secretKey) + result, err := cliApiRequestText("/services/"+serviceID+"/env", "PUT", envContent, publicKey, secretKey) if err != nil { fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset) return false @@ -332,7 +1215,7 @@ func serviceEnvSet(serviceID, envContent, publicKey, secretKey string) bool { } func serviceEnvExport(serviceID, publicKey, secretKey string) { - result := apiRequest("/services/"+serviceID+"/env/export", "POST", map[string]interface{}{}, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceID+"/env/export", "POST", map[string]interface{}{}, publicKey, secretKey) if envContent, ok := result["env"].(string); ok && envContent != "" { fmt.Print(envContent) if !strings.HasSuffix(envContent, "\n") { @@ -342,7 +1225,7 @@ func serviceEnvExport(serviceID, publicKey, secretKey string) { } func serviceEnvDelete(serviceID, publicKey, secretKey string) { - apiRequest("/services/"+serviceID+"/env", "DELETE", nil, publicKey, secretKey) + cliApiRequest("/services/"+serviceID+"/env", "DELETE", nil, publicKey, secretKey) fmt.Printf("%sEnvironment vault deleted%s\n", Green, Reset) } @@ -408,16 +1291,16 @@ func cmdServiceEnv(action, target string, envs envVars, envFile, publicKey, secr } } -func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) { +func cmdExecute(sourceFile string, envs envVars, files inputFilesFlag, artifacts bool, outputDir, network string, vcpu int, publicKey, secretKey string) { code, err := os.ReadFile(sourceFile) if err != nil { fmt.Fprintf(os.Stderr, "%sError reading file: %v%s\n", Red, err, Reset) os.Exit(1) } - language, err := detectLanguage(sourceFile) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError: %v%s\n", Red, err, Reset) + language := DetectLanguage(sourceFile) + if language == "" { + fmt.Fprintf(os.Stderr, "%sError: cannot detect language from extension%s\n", Red, Reset) os.Exit(1) } @@ -467,7 +1350,7 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo payload["vcpu"] = vcpu } - result := apiRequest("/execute", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/execute", "POST", payload, publicKey, secretKey) // Print output if stdout, ok := result["stdout"].(string); ok && stdout != "" { @@ -507,7 +1390,7 @@ func cmdExecute(sourceFile string, envs envVars, files inputFiles, artifacts boo os.Exit(exitCode) } -func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, sessionRestore, sessionSnapshotName string, sessionHot bool, network string, vcpu int, tmux, screen bool, files inputFiles, publicKey, secretKey string) { +func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, sessionRestore, sessionSnapshotName string, sessionHot bool, network string, vcpu int, tmux, screen bool, files inputFilesFlag, publicKey, secretKey string) { if sessionSnapshot != "" { payload := map[string]interface{}{} if sessionSnapshotName != "" { @@ -516,7 +1399,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session if sessionHot { payload["hot"] = true } - result := apiRequest("/sessions/"+sessionSnapshot+"/snapshot", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/sessions/"+sessionSnapshot+"/snapshot", "POST", payload, publicKey, secretKey) fmt.Printf("%sSnapshot created%s\n", Green, Reset) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) @@ -524,8 +1407,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session } if sessionRestore != "" { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - result := apiRequest("/snapshots/"+sessionRestore+"/restore", "POST", nil, publicKey, secretKey) + result := cliApiRequest("/snapshots/"+sessionRestore+"/restore", "POST", nil, publicKey, secretKey) fmt.Printf("%sSession restored from snapshot%s\n", Green, Reset) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) @@ -533,7 +1415,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session } if sessionList != "" { - result := apiRequest("/sessions", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/sessions", "GET", nil, publicKey, secretKey) sessions := result["sessions"].([]interface{}) if len(sessions) == 0 { fmt.Println("No active sessions") @@ -549,7 +1431,7 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session } if sessionKill != "" { - apiRequest("/sessions/"+sessionKill, "DELETE", nil, publicKey, secretKey) + cliApiRequest("/sessions/"+sessionKill, "DELETE", nil, publicKey, secretKey) fmt.Printf("%sSession terminated: %s%s\n", Green, sessionKill, Reset) return } @@ -592,11 +1474,11 @@ func cmdSession(sessionList, sessionKill, sessionShell, sessionSnapshot, session } fmt.Printf("%sCreating session...%s\n", Yellow, Reset) - result := apiRequest("/sessions", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/sessions", "POST", payload, publicKey, secretKey) fmt.Printf("%sSession created: %s%s\n", Green, result["id"], Reset) } -func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceResize, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFiles, envs envVars, envFile, publicKey, secretKey string) { +func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceBootstrap, serviceBootstrapFile, serviceList, serviceInfo, serviceLogs, serviceTail, serviceSleep, serviceWake, serviceDestroy, serviceResize, serviceExecute, serviceCommand, serviceDumpBootstrap, serviceDumpFile, serviceSnapshot, serviceRestore, serviceSnapshotName string, serviceHot bool, network string, vcpu int, files inputFilesFlag, envs envVars, envFile, publicKey, secretKey string) { if serviceSnapshot != "" { payload := map[string]interface{}{} if serviceSnapshotName != "" { @@ -605,7 +1487,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB if serviceHot { payload["hot"] = true } - result := apiRequest("/services/"+serviceSnapshot+"/snapshot", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceSnapshot+"/snapshot", "POST", payload, publicKey, secretKey) fmt.Printf("%sSnapshot created%s\n", Green, Reset) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) @@ -613,8 +1495,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } if serviceRestore != "" { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - result := apiRequest("/snapshots/"+serviceRestore+"/restore", "POST", nil, publicKey, secretKey) + result := cliApiRequest("/snapshots/"+serviceRestore+"/restore", "POST", nil, publicKey, secretKey) fmt.Printf("%sService restored from snapshot%s\n", Green, Reset) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) @@ -622,7 +1503,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } if serviceList != "" { - result := apiRequest("/services", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/services", "GET", nil, publicKey, secretKey) services := result["services"].([]interface{}) if len(services) == 0 { fmt.Println("No services") @@ -654,38 +1535,38 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } if serviceInfo != "" { - result := apiRequest("/services/"+serviceInfo, "GET", nil, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceInfo, "GET", nil, publicKey, secretKey) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) return } if serviceLogs != "" { - result := apiRequest("/services/"+serviceLogs+"/logs", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceLogs+"/logs", "GET", nil, publicKey, secretKey) fmt.Print(result["logs"]) return } if serviceTail != "" { - result := apiRequest("/services/"+serviceTail+"/logs?lines=9000", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceTail+"/logs?lines=9000", "GET", nil, publicKey, secretKey) fmt.Print(result["logs"]) return } if serviceSleep != "" { - apiRequest("/services/"+serviceSleep+"/freeze", "POST", nil, publicKey, secretKey) + cliApiRequest("/services/"+serviceSleep+"/freeze", "POST", nil, publicKey, secretKey) fmt.Printf("%sService frozen: %s%s\n", Green, serviceSleep, Reset) return } if serviceWake != "" { - apiRequest("/services/"+serviceWake+"/unfreeze", "POST", nil, publicKey, secretKey) + cliApiRequest("/services/"+serviceWake+"/unfreeze", "POST", nil, publicKey, secretKey) fmt.Printf("%sService unfreezing: %s%s\n", Green, serviceWake, Reset) return } if serviceDestroy != "" { - apiRequest("/services/"+serviceDestroy, "DELETE", nil, publicKey, secretKey) + cliApiRequest("/services/"+serviceDestroy, "DELETE", nil, publicKey, secretKey) fmt.Printf("%sService destroyed: %s%s\n", Green, serviceDestroy, Reset) return } @@ -696,14 +1577,14 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB os.Exit(1) } payload := map[string]interface{}{"vcpu": vcpu} - apiRequest("/services/"+serviceResize, "PATCH", payload, publicKey, secretKey) + cliApiRequest("/services/"+serviceResize, "PATCH", payload, publicKey, secretKey) fmt.Printf("%sService resized to %d vCPU, %d GB RAM%s\n", Green, vcpu, vcpu*2, Reset) return } if serviceExecute != "" { payload := map[string]interface{}{"command": serviceCommand} - result := apiRequest("/services/"+serviceExecute+"/execute", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceExecute+"/execute", "POST", payload, publicKey, secretKey) if stdout, ok := result["stdout"].(string); ok { fmt.Printf("%s%s%s", Blue, stdout, Reset) } @@ -716,11 +1597,10 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB if serviceDumpBootstrap != "" { fmt.Fprintf(os.Stderr, "Fetching bootstrap script from %s...\n", serviceDumpBootstrap) payload := map[string]interface{}{"command": "cat /tmp/bootstrap.sh"} - result := apiRequest("/services/"+serviceDumpBootstrap+"/execute", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/services/"+serviceDumpBootstrap+"/execute", "POST", payload, publicKey, secretKey) if bootstrap, ok := result["stdout"].(string); ok && bootstrap != "" { if serviceDumpFile != "" { - // Write to file err := os.WriteFile(serviceDumpFile, []byte(bootstrap), 0755) if err != nil { fmt.Fprintf(os.Stderr, "%sError: Could not write to %s: %v%s\n", Red, serviceDumpFile, err, Reset) @@ -728,7 +1608,6 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB } fmt.Printf("Bootstrap saved to %s\n", serviceDumpFile) } else { - // Print to stdout fmt.Print(bootstrap) } } else { @@ -789,7 +1668,7 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB payload["vcpu"] = vcpu } - result := apiRequest("/services", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/services", "POST", payload, publicKey, secretKey) serviceID := result["id"].(string) fmt.Printf("%sService created: %s%s\n", Green, serviceID, Reset) fmt.Printf("Name: %s\n", result["name"]) @@ -811,21 +1690,21 @@ func cmdService(serviceName, servicePorts, serviceDomains, serviceType, serviceB func cmdSnapshot(snapshotList, snapshotInfo, snapshotDelete, snapshotClone, snapshotType, snapshotName, snapshotShell, snapshotPorts, publicKey, secretKey string) { if snapshotList != "" || snapshotList == "" && snapshotInfo == "" && snapshotDelete == "" && snapshotClone == "" { - result := apiRequest("/snapshots", "GET", nil, publicKey, secretKey) + result := cliApiRequest("/snapshots", "GET", nil, publicKey, secretKey) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) return } if snapshotInfo != "" { - result := apiRequest("/snapshots/"+snapshotInfo, "GET", nil, publicKey, secretKey) + result := cliApiRequest("/snapshots/"+snapshotInfo, "GET", nil, publicKey, secretKey) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) return } if snapshotDelete != "" { - apiRequest("/snapshots/"+snapshotDelete, "DELETE", nil, publicKey, secretKey) + cliApiRequest("/snapshots/"+snapshotDelete, "DELETE", nil, publicKey, secretKey) fmt.Printf("%sSnapshot deleted: %s%s\n", Green, snapshotDelete, Reset) return } @@ -852,7 +1731,7 @@ func cmdSnapshot(snapshotList, snapshotInfo, snapshotDelete, snapshotClone, snap } payload["ports"] = ports } - result := apiRequest("/snapshots/"+snapshotClone+"/clone", "POST", payload, publicKey, secretKey) + result := cliApiRequest("/snapshots/"+snapshotClone+"/clone", "POST", payload, publicKey, secretKey) fmt.Printf("%sCreated from snapshot%s\n", Green, Reset) jsonData, _ := json.MarshalIndent(result, "", " ") fmt.Println(string(jsonData)) @@ -893,55 +1772,57 @@ func formatDuration(d time.Duration) string { } func validateKey(publicKey, secretKey string, extend bool) { - url := PortalBase + "/keys/validate" - reqBody := bytes.NewBuffer(nil) + opts := &Options{PublicKey: publicKey, SecretKey: secretKey} + result, err := apiRequest("/keys/validate", "POST", nil, "application/json", &Options{ + PublicKey: publicKey, + SecretKey: secretKey, + Timeout: 30, + }) - req, err := http.NewRequest("POST", url, reqBody) if err != nil { - fmt.Fprintf(os.Stderr, "%sError creating request: %v%s\n", Red, err, Reset) - os.Exit(1) - } + // Try portal endpoint + url := PortalBase + "/keys/validate" + reqBody := bytes.NewBuffer(nil) - // HMAC authentication - timestamp := fmt.Sprintf("%d", time.Now().Unix()) - signature := computeHMAC(secretKey, timestamp, "POST", "/keys/validate", "") - - req.Header.Set("Authorization", "Bearer "+publicKey) - req.Header.Set("X-Timestamp", timestamp) - req.Header.Set("X-Signature", signature) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError making request: %v%s\n", Red, err, Reset) - os.Exit(1) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "%sError reading response: %v%s\n", Red, err, Reset) - os.Exit(1) - } - - var result map[string]interface{} - if err := json.Unmarshal(body, &result); err != nil { - fmt.Fprintf(os.Stderr, "%sError parsing response: %v%s\n", Red, err, Reset) - os.Exit(1) - } - - if resp.StatusCode >= 400 { - // Invalid key - fmt.Printf("%sInvalid%s\n", Red, Reset) - if reason, ok := result["error"].(string); ok { - fmt.Printf("Reason: %s\n", reason) - } else if reason, ok := result["message"].(string); ok { - fmt.Printf("Reason: %s\n", reason) + req, err := http.NewRequest("POST", url, reqBody) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError creating request: %v%s\n", Red, err, Reset) + os.Exit(1) + } + + timestamp := time.Now().Unix() + signature := SignRequest(secretKey, timestamp, "POST", "/keys/validate", "") + + req.Header.Set("Authorization", "Bearer "+publicKey) + req.Header.Set("X-Timestamp", strconv.FormatInt(timestamp, 10)) + req.Header.Set("X-Signature", signature) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "%sError making request: %v%s\n", Red, err, Reset) + os.Exit(1) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(body, &result); err != nil { + fmt.Fprintf(os.Stderr, "%sError parsing response: %v%s\n", Red, err, Reset) + os.Exit(1) + } + + if resp.StatusCode >= 400 { + fmt.Printf("%sInvalid%s\n", Red, Reset) + if reason, ok := result["error"].(string); ok { + fmt.Printf("Reason: %s\n", reason) + } + os.Exit(1) } - os.Exit(1) } + _ = opts // suppress unused warning + valid, _ := result["valid"].(bool) expired, _ := result["expired"].(bool) pubKey, _ := result["public_key"].(string) @@ -949,7 +1830,6 @@ func validateKey(publicKey, secretKey string, extend bool) { status, _ := result["status"].(string) if expired { - // Expired key fmt.Printf("%sExpired%s\n", Red, Reset) fmt.Printf("Public Key: %s\n", pubKey) fmt.Printf("Tier: %s\n", tier) @@ -970,7 +1850,6 @@ func validateKey(publicKey, secretKey string, extend bool) { } if valid { - // Valid key fmt.Printf("%sValid%s\n", Green, Reset) fmt.Printf("Public Key: %s\n", pubKey) fmt.Printf("Tier: %s\n", tier) @@ -979,7 +1858,6 @@ func validateKey(publicKey, secretKey string, extend bool) { if expiresAt, ok := result["expires_at"].(string); ok { fmt.Printf("Expires: %s\n", expiresAt) - // Calculate time remaining expireTime, err := time.Parse(time.RFC3339, expiresAt) if err == nil { remaining := time.Until(expireTime) @@ -1008,7 +1886,6 @@ func validateKey(publicKey, secretKey string, extend bool) { } } } else { - // Invalid key fmt.Printf("%sInvalid%s\n", Red, Reset) if reason, ok := result["error"].(string); ok { fmt.Printf("Reason: %s\n", reason) @@ -1025,7 +1902,7 @@ func main() { // Execute flags var envs envVars - var files inputFiles + var files inputFilesFlag flag.Var(&envs, "e", "Environment variable (KEY=VALUE)") flag.Var(&files, "f", "Input file") artifacts := flag.Bool("a", false, "Return artifacts") @@ -1042,7 +1919,7 @@ func main() { sessionRestore := sessionCmd.String("restore", "", "Restore from snapshot ID") sessionSnapshotName := sessionCmd.String("snapshot-name", "", "Name for snapshot") sessionHot := sessionCmd.Bool("hot", false, "Take snapshot without freezing") - var sessionFiles inputFiles + var sessionFiles inputFilesFlag sessionCmd.Var(&sessionFiles, "f", "Input file") sessionNetwork := sessionCmd.String("n", "", "Network mode") sessionVcpu := sessionCmd.Int("v", 0, "vCPU count") @@ -1053,10 +1930,10 @@ func main() { serviceName := serviceCmd.String("name", "", "Service name") servicePorts := serviceCmd.String("ports", "", "Ports (comma-separated)") serviceDomains := serviceCmd.String("domains", "", "Custom domains (comma-separated)") - serviceType := serviceCmd.String("type", "", "Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)") + serviceType := serviceCmd.String("type", "", "Service type for SRV records") serviceBootstrap := serviceCmd.String("bootstrap", "", "Bootstrap command or URI") serviceBootstrapFile := serviceCmd.String("bootstrap-file", "", "Upload local file as bootstrap script") - var serviceFiles inputFiles + var serviceFiles inputFilesFlag serviceCmd.Var(&serviceFiles, "f", "Input file") var serviceEnvs envVars serviceCmd.Var(&serviceEnvs, "e", "Environment variable (KEY=VALUE)") @@ -1072,7 +1949,7 @@ func main() { serviceExecute := serviceCmd.String("execute", "", "Execute command in service") serviceCommand := serviceCmd.String("command", "", "Command to execute (with -execute)") serviceDumpBootstrap := serviceCmd.String("dump-bootstrap", "", "Dump bootstrap script") - serviceDumpFile := serviceCmd.String("dump-file", "", "File to save bootstrap (with -dump-bootstrap)") + serviceDumpFile := serviceCmd.String("dump-file", "", "File to save bootstrap") serviceSnapshot := serviceCmd.String("snapshot", "", "Create snapshot of service") serviceRestore := serviceCmd.String("restore", "", "Restore from snapshot ID") serviceSnapshotName := serviceCmd.String("snapshot-name", "", "Name for snapshot") @@ -1105,7 +1982,7 @@ func main() { switch os.Args[1] { case "session": sessionCmd.Parse(os.Args[2:]) - publicKey, secretKey := getAPIKeys(*sessionKey) + publicKey, secretKey := cliGetAPIKeys(*sessionKey) net := *sessionNetwork if net == "" { net = *network @@ -1120,7 +1997,6 @@ func main() { case "service": // Check for "service env" subcommand if len(os.Args) > 2 && os.Args[2] == "env" { - // Parse env subcommand: service env [options] envCmd := flag.NewFlagSet("service env", flag.ExitOnError) var envFlags envVars envCmd.Var(&envFlags, "e", "Environment variable (KEY=VALUE)") @@ -1140,13 +2016,13 @@ func main() { } envCmd.Parse(os.Args[argsStart:]) - publicKey, secretKey := getAPIKeys(*envKey) + publicKey, secretKey := cliGetAPIKeys(*envKey) cmdServiceEnv(action, target, envFlags, *envFile, publicKey, secretKey) return } serviceCmd.Parse(os.Args[2:]) - publicKey, secretKey := getAPIKeys(*serviceKey) + publicKey, secretKey := cliGetAPIKeys(*serviceKey) net := *serviceNetwork if net == "" { net = *network @@ -1160,13 +2036,13 @@ func main() { case "snapshot": snapshotCmd.Parse(os.Args[2:]) - publicKey, secretKey := getAPIKeys(*snapshotKey) + publicKey, secretKey := cliGetAPIKeys(*snapshotKey) cmdSnapshot(*snapshotList, *snapshotInfo, *snapshotDelete, *snapshotClone, *snapshotType, *snapshotName, *snapshotShell, *snapshotPorts, publicKey, secretKey) return case "key": keyCmd.Parse(os.Args[2:]) - publicKey, secretKey := getAPIKeys(*keyKey) + publicKey, secretKey := cliGetAPIKeys(*keyKey) validateKey(publicKey, secretKey, *keyExtend) return } @@ -1183,6 +2059,6 @@ func main() { } sourceFile := flag.Arg(0) - publicKey, secretKey := getAPIKeys(*apiKey) + publicKey, secretKey := cliGetAPIKeys(*apiKey) cmdExecute(sourceFile, envs, files, *artifacts, *outputDir, *network, *vcpu, publicKey, secretKey) } diff --git a/un.groovy b/un.groovy index 431f452..6011ed1 100644 --- a/un.groovy +++ b/un.groovy @@ -36,10 +36,80 @@ #!/usr/bin/env groovy -// un.groovy - Unsandbox CLI Client (Groovy Implementation) -// Run: groovy un.groovy [options] -// Requires: UNSANDBOX_API_KEY environment variable +/** + * unsandbox SDK for Groovy - Execute code in secure sandboxes + * https://unsandbox.com | https://api.unsandbox.com/openapi + * + *

Library Usage:

+ *
{@code
+ * import un
+ *
+ * // Simple execution
+ * def result = un.execute("python", 'print("Hello")')
+ * println result.stdout
+ *
+ * // Async execution
+ * def job = un.executeAsync("python", longCode)
+ * def result = un.wait(job.job_id)
+ *
+ * // Using Client class
+ * def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...")
+ * def result = client.execute("python", code)
+ * }
+ * + *

CLI Usage:

+ *
+ * groovy un.groovy script.py
+ * groovy un.groovy -s python 'print("Hello")'
+ * groovy un.groovy session --shell python3
+ * 
+ * + *

Authentication (in priority order):

+ *
    + *
  1. Function arguments: execute(..., publicKey: "...", secretKey: "...")
  2. + *
  3. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY
  4. + *
  5. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line)
  6. + *
+ * + * @author Permacomputer Project + * @version 2.0.0 + */ +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import groovy.json.JsonSlurper +import groovy.json.JsonOutput + +// ============================================================================ +// Configuration +// ============================================================================ + +/** API base URL for unsandbox */ +def API_BASE = 'https://api.unsandbox.com' + +/** Portal base URL for unsandbox */ +def PORTAL_BASE = 'https://unsandbox.com' + +/** Default execution timeout in seconds */ +def DEFAULT_TIMEOUT = 300 + +/** Default TTL for code execution */ +def DEFAULT_TTL = 60 + +/** Maximum vault content size (64KB) */ +def MAX_ENV_CONTENT_SIZE = 65536 + +/** Polling delays (ms) - exponential backoff */ +def POLL_DELAYS = [300, 450, 700, 900, 650, 1600, 2000] + +// ANSI colors +def BLUE = '\033[34m' +def RED = '\033[31m' +def GREEN = '\033[32m' +def YELLOW = '\033[33m' +def RESET = '\033[0m' + +/** Extension to language mapping */ def EXT_MAP = [ '.java': 'java', '.kt': 'kotlin', '.cs': 'csharp', '.fs': 'fsharp', '.groovy': 'groovy', '.dart': 'dart', '.scala': 'scala', @@ -50,21 +120,860 @@ def EXT_MAP = [ '.lisp': 'commonlisp', '.erl': 'erlang', '.ex': 'elixir', '.jl': 'julia', '.r': 'r', '.cr': 'crystal', '.f90': 'fortran', '.cob': 'cobol', '.pro': 'prolog', '.forth': 'forth', '.tcl': 'tcl', - '.raku': 'raku', '.d': 'd', '.nim': 'nim', '.zig': 'zig', '.v': 'v' + '.raku': 'raku', '.d': 'd', '.nim': 'nim', '.zig': 'zig', '.v': 'v', + '.awk': 'awk', '.m': 'objc' ] -def API_BASE = 'https://api.unsandbox.com' -def PORTAL_BASE = 'https://unsandbox.com' -def MAX_ENV_CONTENT_SIZE = 65536 -def BLUE = '\033[34m' -def RED = '\033[31m' -def GREEN = '\033[32m' -def YELLOW = '\033[33m' -def RESET = '\033[0m' +// ============================================================================ +// Exceptions +// ============================================================================ + +/** + * Base exception for unsandbox errors. + */ +class UnsandboxError extends Exception { + UnsandboxError(String message) { + super(message) + } +} + +/** + * Authentication failed - invalid or missing credentials. + */ +class AuthenticationError extends UnsandboxError { + AuthenticationError(String message) { + super(message) + } +} + +/** + * Code execution failed. + */ +class ExecutionError extends UnsandboxError { + Integer exitCode + String stderr + + ExecutionError(String message, Integer exitCode = null, String stderr = null) { + super(message) + this.exitCode = exitCode + this.stderr = stderr + } +} + +/** + * API request failed. + */ +class APIError extends UnsandboxError { + Integer statusCode + String response + + APIError(String message, Integer statusCode = null, String response = null) { + super(message) + this.statusCode = statusCode + this.response = response + } +} + +/** + * Execution timed out. + */ +class TimeoutError extends UnsandboxError { + TimeoutError(String message) { + super(message) + } +} + +// ============================================================================ +// HMAC Authentication +// ============================================================================ + +/** + * Generate HMAC-SHA256 signature for API request. + * + *

Signature format: HMAC-SHA256(secretKey, "timestamp:METHOD:path:body")

+ * + * @param secretKey The secret key for HMAC + * @param timestamp Unix timestamp + * @param method HTTP method (GET, POST, etc.) + * @param path API endpoint path + * @param body Request body (empty string if none) + * @return Hex-encoded signature + */ +def signRequest(String secretKey, long timestamp, String method, String path, String body = "") { + def message = "${timestamp}:${method}:${path}:${body}" + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + return mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() +} + +/** + * Get API credentials in priority order. + * + *
    + *
  1. Function arguments
  2. + *
  3. Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY)
  4. + *
  5. Config file (~/.unsandbox/accounts.csv)
  6. + *
+ * + * @param publicKey Optional public key argument + * @param secretKey Optional secret key argument + * @param accountIndex Account index in config file (default 0) + * @return Tuple of [publicKey, secretKey] + * @throws AuthenticationError if no credentials found + */ +def getCredentials(String publicKey = null, String secretKey = null, int accountIndex = 0) { + // Priority 1: Function arguments + if (publicKey && secretKey) { + return [publicKey, secretKey] + } + + // Priority 2: Environment variables + def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') + def envSk = System.getenv('UNSANDBOX_SECRET_KEY') + if (envPk && envSk) { + return [envPk, envSk] + } + + // Priority 3: Config file + def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') + if (accountsPath.exists()) { + try { + def lines = accountsPath.text.trim().split('\n') + def validAccounts = [] + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0] + def sk = parts[1] + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + if (validAccounts && accountIndex < validAccounts.size()) { + return validAccounts[accountIndex] + } + } catch (Exception e) { + // Ignore file read errors + } + } + + throw new AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " + + "or create ~/.unsandbox/accounts.csv, or pass credentials to function." + ) +} + +// Legacy compatibility +def getApiKeys(argsKey) { + def publicKey = System.getenv('UNSANDBOX_PUBLIC_KEY') + def secretKey = System.getenv('UNSANDBOX_SECRET_KEY') + + if (!publicKey || !secretKey) { + def legacyKey = argsKey ?: System.getenv('UNSANDBOX_API_KEY') + if (!legacyKey) { + System.err.println("${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}") + System.exit(1) + } + return [legacyKey, null] + } + + return [publicKey, secretKey] +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +/** + * Make authenticated API request with HMAC signature. + * + * @param endpoint API endpoint path + * @param method HTTP method + * @param data Request body data (will be JSON-encoded if Map) + * @param publicKey API public key + * @param secretKey API secret key + * @param timeout Request timeout in seconds + * @param contentType Content-Type header + * @return Parsed JSON response as Map + * @throws APIError on request failure + */ +def apiRequest(String endpoint, String method, data, String publicKey, String secretKey, + int timeout = DEFAULT_TIMEOUT, String contentType = 'application/json') { + def tempFile = File.createTempFile('un_request_', '.json') + try { + def body = "" + if (data) { + body = data instanceof Map ? JsonOutput.toJson(data) : data.toString() + tempFile.text = body + } + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', "Content-Type: ${contentType}"] + + // Add HMAC authentication headers if secretKey is provided + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def signature = signRequest(secretKey, timestamp, method, endpoint, body) + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + if (data) { + curlCmd += ['-d', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + if (proc.exitValue() != 0) { + throw new APIError("curl failed with exit code ${proc.exitValue()}") + } + + // Check for timestamp authentication errors + if (output.toLowerCase().contains('timestamp') && + (output.contains('401') || output.toLowerCase().contains('expired') || output.toLowerCase().contains('invalid'))) { + throw new AuthenticationError( + "Request timestamp expired. Your system clock may be out of sync. " + + "Run: sudo ntpdate -s time.nist.gov" + ) + } + + try { + return new JsonSlurper().parseText(output) + } catch (Exception e) { + return [raw: output] + } + } finally { + tempFile.delete() + } +} + +def apiRequestPatch(endpoint, data, publicKey, secretKey) { + return apiRequest(endpoint, 'PATCH', data, publicKey, secretKey) +} + +def apiRequestText(endpoint, method, body, publicKey, secretKey) { + def tempFile = File.createTempFile('un_env_', '.txt') + try { + if (body) { + tempFile.text = body + } + + def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", + '-H', 'Content-Type: text/plain'] + + if (secretKey) { + def timestamp = (System.currentTimeMillis() / 1000) as long + def message = "${timestamp}:${method}:${endpoint}:${body ?: ''}" + + def mac = Mac.getInstance("HmacSHA256") + mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) + def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() + + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + curlCmd += ['-H', "X-Timestamp: ${timestamp}"] + curlCmd += ['-H', "X-Signature: ${signature}"] + } else { + curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] + } + + if (body) { + curlCmd += ['--data-binary', "@${tempFile.absolutePath}"] + } + + def proc = curlCmd.execute() + def output = proc.text + proc.waitFor() + + return proc.exitValue() == 0 + } finally { + tempFile.delete() + } +} + +// ============================================================================ +// Core Library Functions +// ============================================================================ + +/** + * Execute code synchronously and return results. + * + * @param language Programming language (python, javascript, go, rust, etc.) + * @param code Source code to execute + * @param options Optional parameters: + *
    + *
  • env: Map of environment variables
  • + *
  • inputFiles: List of [filename: "...", content: "..."] or [filename: "...", contentBase64: "..."]
  • + *
  • networkMode: "zerotrust" (no network) or "semitrusted" (internet access)
  • + *
  • ttl: Execution timeout in seconds (1-900, default 60)
  • + *
  • vcpu: Virtual CPUs (1-8, default 1)
  • + *
  • returnArtifact: Return compiled binary
  • + *
  • returnWasmArtifact: Compile to WebAssembly
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: success, stdout, stderr, exit_code, language, job_id, total_time_ms, network_mode, artifacts + * @throws AuthenticationError Invalid or missing credentials + * @throws ExecutionError Code execution failed + * @throws APIError API request failed + * + *
{@code
+ * def result = un.execute("python", 'print("Hello World")')
+ * println result.stdout  // "Hello World\n"
+ * }
+ */ +def execute(String language, String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex ?: 0 + ) + + def payload = [ + language: language, + code: code, + network_mode: options.networkMode ?: 'zerotrust', + ttl: options.ttl ?: DEFAULT_TTL, + vcpu: options.vcpu ?: 1 + ] + + if (options.env) { + payload.env = options.env + } + + if (options.inputFiles) { + payload.input_files = options.inputFiles.collect { f -> + if (f.contentBase64 || f.content_base64) { + return [filename: f.filename, content_base64: f.contentBase64 ?: f.content_base64] + } else if (f.content) { + return [filename: f.filename, content_base64: f.content.bytes.encodeBase64().toString()] + } + return f + } + } + + if (options.returnArtifact) payload.return_artifact = true + if (options.returnWasmArtifact) payload.return_wasm_artifact = true + + return apiRequest('/execute', 'POST', payload, publicKey, secretKey) +} + +/** + * Execute code asynchronously. Returns immediately with job_id for polling. + * + * @param language Programming language + * @param code Source code to execute + * @param options Same options as execute() + * @return Map with keys: job_id, status ("pending") + * + *
{@code
+ * def job = un.executeAsync("python", longRunningCode)
+ * println "Job submitted: ${job.job_id}"
+ * def result = un.wait(job.job_id)
+ * }
+ */ +def executeAsync(String language, String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex ?: 0 + ) + + def payload = [ + language: language, + code: code, + network_mode: options.networkMode ?: 'zerotrust', + ttl: options.ttl ?: DEFAULT_TTL, + vcpu: options.vcpu ?: 1 + ] + + if (options.env) payload.env = options.env + if (options.inputFiles) { + payload.input_files = options.inputFiles.collect { f -> + if (f.contentBase64 || f.content_base64) { + return [filename: f.filename, content_base64: f.contentBase64 ?: f.content_base64] + } else if (f.content) { + return [filename: f.filename, content_base64: f.content.bytes.encodeBase64().toString()] + } + return f + } + } + if (options.returnArtifact) payload.return_artifact = true + if (options.returnWasmArtifact) payload.return_wasm_artifact = true + + return apiRequest('/execute/async', 'POST', payload, publicKey, secretKey) +} + +/** + * Execute code with automatic language detection from shebang. + * + * @param code Source code with shebang (e.g., #!/usr/bin/env python3) + * @param options Optional parameters (env, networkMode, ttl, publicKey, secretKey) + * @return Map with keys: success, stdout, stderr, exit_code, detected_language, ... + * + *
{@code
+ * def code = '''#!/usr/bin/env python3
+ * print("Auto-detected!")
+ * '''
+ * def result = un.run(code)
+ * println result.detected_language  // "python"
+ * }
+ */ +def run(String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex ?: 0 + ) + + def ttl = options.ttl ?: DEFAULT_TTL + def networkMode = options.networkMode ?: 'zerotrust' + def endpoint = "/run?ttl=${ttl}&network_mode=${networkMode}" + + if (options.env) { + endpoint += "&env=${URLEncoder.encode(JsonOutput.toJson(options.env), 'UTF-8')}" + } + + return apiRequest(endpoint, 'POST', code, publicKey, secretKey, DEFAULT_TIMEOUT, 'text/plain') +} + +/** + * Execute code asynchronously with automatic language detection. + * + * @param code Source code with shebang + * @param options Optional parameters + * @return Map with keys: job_id, detected_language, status ("pending") + */ +def runAsync(String code, Map options = [:]) { + def (publicKey, secretKey) = getCredentials( + options.publicKey, + options.secretKey, + options.accountIndex ?: 0 + ) + + def ttl = options.ttl ?: DEFAULT_TTL + def networkMode = options.networkMode ?: 'zerotrust' + def endpoint = "/run/async?ttl=${ttl}&network_mode=${networkMode}" + + if (options.env) { + endpoint += "&env=${URLEncoder.encode(JsonOutput.toJson(options.env), 'UTF-8')}" + } + + return apiRequest(endpoint, 'POST', code, publicKey, secretKey, DEFAULT_TIMEOUT, 'text/plain') +} + +// ============================================================================ +// Job Management +// ============================================================================ + +/** + * Get job status and results. + * + * @param jobId Job ID from executeAsync or runAsync + * @param options Optional parameters (publicKey, secretKey) + * @return Map with keys: job_id, status, result (if completed), timestamps + * + *

Status values: pending, running, completed, failed, timeout, cancelled

+ */ +def getJob(String jobId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/jobs/${jobId}", 'GET', null, publicKey, secretKey) +} + +/** + * Wait for job completion with exponential backoff polling. + * + * @param jobId Job ID from executeAsync or runAsync + * @param options Optional parameters: + *
    + *
  • maxPolls: Maximum number of poll attempts (default 100)
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Final job result Map + * @throws TimeoutError Max polls exceeded + * @throws ExecutionError Job failed + * + *
{@code
+ * def job = un.executeAsync("python", code)
+ * def result = un.wait(job.job_id)
+ * println result.stdout
+ * }
+ */ +def wait(String jobId, Map options = [:]) { + def maxPolls = options.maxPolls ?: 100 + def terminalStates = ['completed', 'failed', 'timeout', 'cancelled'] as Set + + for (int i = 0; i < maxPolls; i++) { + // Exponential backoff delay + def delayIdx = Math.min(i, POLL_DELAYS.size() - 1) + Thread.sleep(POLL_DELAYS[delayIdx]) + + def result = getJob(jobId, options) + def status = result.status ?: '' + + if (status in terminalStates) { + if (status == 'failed') { + throw new ExecutionError( + "Job failed: ${result.error ?: 'Unknown error'}", + result.exit_code, + result.stderr + ) + } + if (status == 'timeout') { + throw new TimeoutError("Job timed out: ${jobId}") + } + return result + } + } + + throw new TimeoutError("Max polls (${maxPolls}) exceeded for job ${jobId}") +} + +/** + * Cancel a running job. + * + * @param jobId Job ID to cancel + * @param options Optional parameters (publicKey, secretKey) + * @return Partial output and artifacts collected before cancellation + */ +def cancelJob(String jobId, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + return apiRequest("/jobs/${jobId}", 'DELETE', null, publicKey, secretKey) +} + +/** + * List all active jobs for this API key. + * + * @param options Optional parameters (publicKey, secretKey) + * @return List of job summary Maps with keys: job_id, language, status, submitted_at + */ +def listJobs(Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/jobs', 'GET', null, publicKey, secretKey) + return result.jobs ?: [] +} + +// ============================================================================ +// Image Generation +// ============================================================================ + +/** + * Generate images from text prompt. + * + * @param prompt Text description of the image to generate + * @param options Optional parameters: + *
    + *
  • model: Model to use (optional, uses default)
  • + *
  • size: Image size (e.g., "1024x1024", "512x512")
  • + *
  • quality: "standard" or "hd"
  • + *
  • n: Number of images to generate
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: images (list of base64 or URLs), created_at + * + *
{@code
+ * def result = un.image("A sunset over mountains")
+ * println result.images[0]
+ * }
+ */ +def image(String prompt, Map options = [:]) { + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + + def payload = [ + prompt: prompt, + size: options.size ?: '1024x1024', + quality: options.quality ?: 'standard', + n: options.n ?: 1 + ] + if (options.model) payload.model = options.model + + return apiRequest('/image', 'POST', payload, publicKey, secretKey) +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** Cache max age for languages (1 hour in milliseconds) */ +def LANGUAGES_CACHE_MAX_AGE = 3600000 + +/** + * Get list of supported programming languages. + * + *

Results are cached in ~/.unsandbox/languages.json for 1 hour.

+ * + * @param options Optional parameters: + *
    + *
  • forceRefresh: Bypass cache and fetch fresh data
  • + *
  • publicKey: API public key
  • + *
  • secretKey: API secret key
  • + *
+ * @return Map with keys: languages (list), count, aliases (map) + */ +def languages(Map options = [:]) { + def cachePath = new File(System.getProperty('user.home'), '.unsandbox/languages.json') + + // Check cache unless force refresh + if (!options.forceRefresh && cachePath.exists()) { + try { + def cacheAge = System.currentTimeMillis() - cachePath.lastModified() + if (cacheAge < LANGUAGES_CACHE_MAX_AGE) { + return new JsonSlurper().parseText(cachePath.text) + } + } catch (Exception e) { + // Cache read failed, fetch from API + } + } + + // Fetch from API + def (publicKey, secretKey) = getCredentials(options.publicKey, options.secretKey) + def result = apiRequest('/languages', 'GET', null, publicKey, secretKey) + + // Save to cache + try { + cachePath.parentFile.mkdirs() + cachePath.text = JsonOutput.toJson(result) + } catch (Exception e) { + // Cache write failed, continue anyway + } + + return result +} + +/** + * Detect programming language from file extension or shebang. + * + * @param filename File path + * @return Language name or null if undetected + */ +def detectLanguage(String filename) { + def dotIndex = filename.lastIndexOf('.') + if (dotIndex == -1) return null + + def ext = filename.substring(dotIndex) + def language = EXT_MAP[ext] + if (language) return language + + // Try shebang + try { + def file = new File(filename) + if (file.exists()) { + def firstLine = file.readLines()[0] + if (firstLine?.startsWith('#!')) { + if (firstLine.contains('python')) return 'python' + if (firstLine.contains('node')) return 'javascript' + if (firstLine.contains('ruby')) return 'ruby' + if (firstLine.contains('perl')) return 'perl' + if (firstLine.contains('bash') || firstLine.contains('/sh')) return 'bash' + if (firstLine.contains('lua')) return 'lua' + if (firstLine.contains('php')) return 'php' + } + } + } catch (Exception e) { + // Ignore file read errors + } + + return null +} + +// ============================================================================ +// Client Class +// ============================================================================ + +/** + * Unsandbox API client with stored credentials. + * + *

Use the Client class when making multiple API calls to avoid + * repeated credential resolution.

+ * + *
{@code
+ * // With explicit credentials
+ * def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...")
+ * def result = client.execute("python", 'print("Hello")')
+ *
+ * // Or load from environment/config automatically
+ * def client = new un.Client()
+ * def result = client.execute("python", code)
+ * }
+ * + * @author Permacomputer Project + */ +class Client { + String publicKey + String secretKey + + /** + * Initialize client with credentials. + * + * @param options Optional parameters: + *
    + *
  • publicKey: API public key (unsb-pk-...)
  • + *
  • secretKey: API secret key (unsb-sk-...)
  • + *
  • accountIndex: Account index in ~/.unsandbox/accounts.csv (default 0)
  • + *
+ */ + Client(Map options = [:]) { + def creds = getCredentialsStatic( + options.publicKey, + options.secretKey, + options.accountIndex ?: 0 + ) + this.publicKey = creds[0] + this.secretKey = creds[1] + } + + private static getCredentialsStatic(String publicKey, String secretKey, int accountIndex) { + if (publicKey && secretKey) { + return [publicKey, secretKey] + } + + def envPk = System.getenv('UNSANDBOX_PUBLIC_KEY') + def envSk = System.getenv('UNSANDBOX_SECRET_KEY') + if (envPk && envSk) { + return [envPk, envSk] + } + + def accountsPath = new File(System.getProperty('user.home'), '.unsandbox/accounts.csv') + if (accountsPath.exists()) { + try { + def lines = accountsPath.text.trim().split('\n') + def validAccounts = [] + lines.each { line -> + def trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) return + if (trimmed.contains(',')) { + def parts = trimmed.split(',', 2) + def pk = parts[0] + def sk = parts[1] + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts << [pk, sk] + } + } + } + if (validAccounts && accountIndex < validAccounts.size()) { + return validAccounts[accountIndex] + } + } catch (Exception e) { + // Ignore + } + } + + throw new AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY." + ) + } + + /** + * Execute code synchronously. + * @see #execute(String, String, Map) + */ + def execute(String language, String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.execute(language, code, options) + } + + /** + * Execute code asynchronously. + * @see #executeAsync(String, String, Map) + */ + def executeAsync(String language, String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.executeAsync(language, code, options) + } + + /** + * Execute with auto-detect. + * @see #run(String, Map) + */ + def run(String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.run(code, options) + } + + /** + * Execute async with auto-detect. + * @see #runAsync(String, Map) + */ + def runAsync(String code, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.runAsync(code, options) + } + + /** + * Get job status. + * @see #getJob(String, Map) + */ + def getJob(String jobId) { + return binding.getJob(jobId, [publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * Wait for job completion. + * @see #wait(String, Map) + */ + def wait(String jobId, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.wait(jobId, options) + } + + /** + * Cancel a job. + * @see #cancelJob(String, Map) + */ + def cancelJob(String jobId) { + return binding.cancelJob(jobId, [publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * List active jobs. + * @see #listJobs(Map) + */ + def listJobs() { + return binding.listJobs([publicKey: this.publicKey, secretKey: this.secretKey]) + } + + /** + * Generate image. + * @see #image(String, Map) + */ + def image(String prompt, Map options = [:]) { + options.publicKey = this.publicKey + options.secretKey = this.secretKey + return binding.image(prompt, options) + } + + /** + * Get supported languages. + * @see #languages(Map) + */ + def languages() { + return binding.languages([publicKey: this.publicKey, secretKey: this.secretKey]) + } +} + +// ============================================================================ +// CLI Support Classes and Functions +// ============================================================================ class Args { String command = null String sourceFile = null + String inlineLang = null String apiKey = null String network = null Integer vcpu = 0 @@ -117,159 +1026,6 @@ class Args { String envTarget = null } -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -def getApiKeys(argsKey) { - def publicKey = System.getenv('UNSANDBOX_PUBLIC_KEY') - def secretKey = System.getenv('UNSANDBOX_SECRET_KEY') - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if (!publicKey || !secretKey) { - def legacyKey = argsKey ?: System.getenv('UNSANDBOX_API_KEY') - if (!legacyKey) { - System.err.println("${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}") - System.exit(1) - } - return [legacyKey, null] - } - - return [publicKey, secretKey] -} - -def detectLanguage(filename) { - def dotIndex = filename.lastIndexOf('.') - if (dotIndex == -1) { - System.err.println("${RED}Error: No file extension${RESET}") - System.exit(1) - } - def ext = filename.substring(dotIndex) - def language = EXT_MAP[ext] - if (!language) { - System.err.println("${RED}Error: Unsupported extension: ${ext}${RESET}") - System.exit(1) - } - return language -} - -def apiRequest(endpoint, method, data, publicKey, secretKey) { - def tempFile = File.createTempFile('un_request_', '.json') - try { - def body = data ?: "" - if (data) { - tempFile.text = data - } - - def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", - '-H', 'Content-Type: application/json'] - - // Add HMAC authentication headers if secretKey is provided - if (secretKey) { - def timestamp = (System.currentTimeMillis() / 1000) as long - def message = "${timestamp}:${method}:${endpoint}:${body}" - - def mac = Mac.getInstance("HmacSHA256") - mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) - def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() - - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - curlCmd += ['-H', "X-Timestamp: ${timestamp}"] - curlCmd += ['-H', "X-Signature: ${signature}"] - } else { - // Legacy API key authentication - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - } - - if (data) { - curlCmd += ['-d', "@${tempFile.absolutePath}"] - } - - def proc = curlCmd.execute() - def output = proc.text - proc.waitFor() - - if (proc.exitValue() != 0) { - System.err.println("${RED}Error: curl failed${RESET}") - System.exit(1) - } - - // Check for timestamp authentication errors - if (output.toLowerCase().contains('timestamp') && - (output.contains('401') || output.toLowerCase().contains('expired') || output.toLowerCase().contains('invalid'))) { - System.err.println("${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}") - System.err.println("${YELLOW}Your computer's clock may have drifted.${RESET}") - System.err.println("Check your system time and sync with NTP if needed:") - System.err.println(" Linux: sudo ntpdate -s time.nist.gov") - System.err.println(" macOS: sudo sntp -sS time.apple.com") - System.err.println(" Windows: w32tm /resync") - System.exit(1) - } - - return output - } finally { - tempFile.delete() - } -} - -def apiRequestPatch(endpoint, data, publicKey, secretKey) { - def tempFile = File.createTempFile('un_request_', '.json') - try { - def body = data ?: "" - if (data) { - tempFile.text = data - } - - def curlCmd = ['curl', '-s', '-X', 'PATCH', "${API_BASE}${endpoint}", - '-H', 'Content-Type: application/json'] - - // Add HMAC authentication headers if secretKey is provided - if (secretKey) { - def timestamp = (System.currentTimeMillis() / 1000) as long - def message = "${timestamp}:PATCH:${endpoint}:${body}" - - def mac = Mac.getInstance("HmacSHA256") - mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) - def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() - - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - curlCmd += ['-H', "X-Timestamp: ${timestamp}"] - curlCmd += ['-H', "X-Signature: ${signature}"] - } else { - // Legacy API key authentication - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - } - - if (data) { - curlCmd += ['-d', "@${tempFile.absolutePath}"] - } - - def proc = curlCmd.execute() - def output = proc.text - proc.waitFor() - - if (proc.exitValue() != 0) { - System.err.println("${RED}Error: curl failed${RESET}") - System.exit(1) - } - - // Check for timestamp authentication errors - if (output.toLowerCase().contains('timestamp') && - (output.contains('401') || output.toLowerCase().contains('expired') || output.toLowerCase().contains('invalid'))) { - System.err.println("${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}") - System.err.println("${YELLOW}Your computer's clock may have drifted.${RESET}") - System.err.println("Check your system time and sync with NTP if needed:") - System.err.println(" Linux: sudo ntpdate -s time.nist.gov") - System.err.println(" macOS: sudo sntp -sS time.apple.com") - System.err.println(" Windows: w32tm /resync") - System.exit(1) - } - - return output - } finally { - tempFile.delete() - } -} - def readEnvFile(filename) { def file = new File(filename) if (!file.exists()) { @@ -282,12 +1038,10 @@ def readEnvFile(filename) { def buildEnvContent(envs, envFile) { def result = new StringBuilder() - // Add -e flags envs.each { env -> result.append(env).append('\n') } - // Add content from env file if (envFile) { def content = readEnvFile(envFile) content.split('\n').each { line -> @@ -301,46 +1055,6 @@ def buildEnvContent(envs, envFile) { return result.toString() } -def apiRequestText(endpoint, method, body, publicKey, secretKey) { - def tempFile = File.createTempFile('un_env_', '.txt') - try { - if (body) { - tempFile.text = body - } - - def curlCmd = ['curl', '-s', '-X', method, "${API_BASE}${endpoint}", - '-H', 'Content-Type: text/plain'] - - // Add HMAC authentication headers if secretKey is provided - if (secretKey) { - def timestamp = (System.currentTimeMillis() / 1000) as long - def message = "${timestamp}:${method}:${endpoint}:${body ?: ''}" - - def mac = Mac.getInstance("HmacSHA256") - mac.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256")) - def signature = mac.doFinal(message.getBytes("UTF-8")).encodeHex().toString() - - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - curlCmd += ['-H', "X-Timestamp: ${timestamp}"] - curlCmd += ['-H', "X-Signature: ${signature}"] - } else { - curlCmd += ['-H', "Authorization: Bearer ${publicKey}"] - } - - if (body) { - curlCmd += ['--data-binary', "@${tempFile.absolutePath}"] - } - - def proc = curlCmd.execute() - def output = proc.text - proc.waitFor() - - return proc.exitValue() == 0 - } finally { - tempFile.delete() - } -} - def serviceEnvSet(serviceId, content, publicKey, secretKey) { return apiRequestText("/services/${serviceId}/env", 'PUT', content, publicKey, secretKey) } @@ -351,7 +1065,7 @@ def cmdServiceEnv(args) { switch (args.envAction) { case 'status': def output = apiRequest("/services/${args.envTarget}/env", 'GET', null, publicKey, secretKey) - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) break case 'set': if (!args.svcEnvs && !args.svcEnvFile) { @@ -369,7 +1083,7 @@ def cmdServiceEnv(args) { break case 'export': def output = apiRequest("/services/${args.envTarget}/env/export", 'POST', null, publicKey, secretKey) - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) break case 'delete': apiRequest("/services/${args.envTarget}/env", 'DELETE', null, publicKey, secretKey) @@ -383,133 +1097,117 @@ def cmdServiceEnv(args) { def cmdExecute(args) { def (publicKey, secretKey) = getApiKeys(args.apiKey) - def file = new File(args.sourceFile) - if (!file.exists()) { - System.err.println("${RED}Error: File not found: ${args.sourceFile}${RESET}") - System.exit(1) - } - def code = file.text - def language = detectLanguage(args.sourceFile) + String code + String language - def escapedCode = code.replace('\\', '\\\\') - .replace('"', '\\"') - .replace('\n', '\\n') - .replace('\r', '\\r') - .replace('\t', '\\t') - - def json = """{"language":"${language}","code":"${escapedCode}"""" - - if (args.env) { - def envJson = args.env.collect { e -> - def parts = e.split('=', 2) - if (parts.size() == 2) { - return "\"${parts[0]}\":\"${parts[1]}\"" - } - return null - }.findAll { it != null }.join(',') - if (envJson) { - json += ""","env":{${envJson}}""" + if (args.inlineLang) { + language = args.inlineLang + code = args.sourceFile ?: "" + } else { + def file = new File(args.sourceFile) + if (!file.exists()) { + System.err.println("${RED}Error: File not found: ${args.sourceFile}${RESET}") + System.exit(1) + } + code = file.text + language = detectLanguage(args.sourceFile) + if (!language) { + System.err.println("${RED}Error: Cannot detect language for ${args.sourceFile}${RESET}") + System.exit(1) } } + def options = [ + networkMode: args.network ?: 'zerotrust', + vcpu: args.vcpu > 0 ? args.vcpu : 1, + publicKey: publicKey, + secretKey: secretKey + ] + + if (args.env) { + def envMap = [:] + args.env.each { e -> + def parts = e.split('=', 2) + if (parts.size() == 2) { + envMap[parts[0]] = parts[1] + } + } + if (envMap) options.env = envMap + } + if (args.files) { - def filesJson = args.files.collect { filepath -> + options.inputFiles = args.files.collect { filepath -> def f = new File(filepath) if (!f.exists()) { System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") System.exit(1) } - def content = f.bytes.encodeBase64().toString() - return """{"filename":"${f.name}","content_base64":"${content}"}""" - }.join(',') - json += ""","input_files":[${filesJson}]""" - } - - if (args.artifacts) { - json += ',"return_artifacts":true' - } - if (args.network) { - json += ""","network":"${args.network}"""" - } - if (args.vcpu > 0) { - json += ""","vcpu":${args.vcpu}""" - } - - json += '}' - - def output = apiRequest('/execute', 'POST', json, publicKey, secretKey) - - def stdoutMatch = output =~ /"stdout":"((?:[^"\\]|\\.)*)"/ - def stderrMatch = output =~ /"stderr":"((?:[^"\\]|\\.)*)"/ - def exitCodeMatch = output =~ /"exit_code":(\d+)/ - - if (stdoutMatch.find()) { - def stdout = stdoutMatch.group(1) - .replace('\\n', '\n') - .replace('\\t', '\t') - .replace('\\"', '"') - .replace('\\\\', '\\') - print("${BLUE}${stdout}${RESET}") - } - - if (stderrMatch.find()) { - def stderr = stderrMatch.group(1) - .replace('\\n', '\n') - .replace('\\t', '\t') - .replace('\\"', '"') - .replace('\\\\', '\\') - System.err.print("${RED}${stderr}${RESET}") - } - - if (args.artifacts) { - def artifactsMatch = output =~ /"artifacts":\[(.*?)\]/ - if (artifactsMatch.find()) { - def outDir = args.outputDir ?: '.' - new File(outDir).mkdirs() - System.err.println("${GREEN}Artifacts saved to ${outDir}${RESET}") + return [filename: f.name, contentBase64: f.bytes.encodeBase64().toString()] } } - def exitCode = 0 - if (exitCodeMatch.find()) { - exitCode = exitCodeMatch.group(1).toInteger() + if (args.artifacts) { + options.returnArtifact = true } - System.exit(exitCode) + def result = execute(language, code, options) + + if (result.stdout) { + print("${BLUE}${result.stdout}${RESET}") + } + if (result.stderr) { + System.err.print("${RED}${result.stderr}${RESET}") + } + + if (args.artifacts && result.artifacts) { + def outDir = args.outputDir ?: '.' + new File(outDir).mkdirs() + result.artifacts.each { artifact -> + def filename = artifact.filename ?: 'artifact' + def content = artifact.content_base64.decodeBase64() + def filepath = new File(outDir, filename) + filepath.bytes = content + "chmod 755 ${filepath.absolutePath}".execute().waitFor() + System.err.println("${GREEN}Saved: ${filepath.absolutePath}${RESET}") + } + } + + System.exit(result.exit_code ?: 0) } def cmdSession(args) { def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.sessionSnapshot) { - def json = "{" - if (args.sessionSnapshotName) { - json += "\"name\":\"${args.sessionSnapshotName.replace('\\', '\\\\').replace('"', '\\"')}\"" - } - if (args.sessionHot) { - if (args.sessionSnapshotName) json += "," - json += "\"hot\":true" - } - json += "}" - def output = apiRequest("/sessions/${args.sessionSnapshot}/snapshot", 'POST', json, publicKey, secretKey) + def payload = [:] + if (args.sessionSnapshotName) payload.name = args.sessionSnapshotName + if (args.sessionHot) payload.hot = true + def output = apiRequest("/sessions/${args.sessionSnapshot}/snapshot", 'POST', payload, publicKey, secretKey) println("${GREEN}Snapshot created${RESET}") - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.sessionRestore) { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - def output = apiRequest("/snapshots/${args.sessionRestore}/restore", 'POST', '{}', publicKey, secretKey) + def output = apiRequest("/snapshots/${args.sessionRestore}/restore", 'POST', [:], publicKey, secretKey) println("${GREEN}Session restored from snapshot${RESET}") - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.sessionList) { def output = apiRequest('/sessions', 'GET', null, publicKey, secretKey) - println("%-40s %-10s %-10s %s".format("ID", "Shell", "Status", "Created")) - println("No sessions (list parsing not implemented)") + def sessions = output.sessions ?: [] + if (sessions.isEmpty()) { + println("No active sessions") + } else { + println(String.format("%-40s %-10s %-10s %s", "ID", "Shell", "Status", "Created")) + sessions.each { s -> + println(String.format("%-40s %-10s %-10s %s", + s.id ?: '', s.shell ?: '', s.status ?: '', s.created_at ?: '')) + } + } return } @@ -519,38 +1217,24 @@ def cmdSession(args) { return } - def json = """{"shell":"${args.sessionShell ?: 'bash'}"""" - if (args.network) { - json += ""","network":"${args.network}"""" - } - if (args.vcpu > 0) { - json += ""","vcpu":${args.vcpu}""" - } + def payload = [shell: args.sessionShell ?: 'bash'] + if (args.network) payload.network = args.network + if (args.vcpu > 0) payload.vcpu = args.vcpu - // Add input files if (args.files) { - def filesJson = args.files.collect { filepath -> + payload.input_files = args.files.collect { filepath -> def f = new File(filepath) if (!f.exists()) { System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") System.exit(1) } - def content = f.bytes.encodeBase64().toString() - return """{"filename":"${f.name}","content_base64":"${content}"}""" - }.join(',') - json += ""","input_files":[${filesJson}]""" + return [filename: f.name, content_base64: f.bytes.encodeBase64().toString()] + } } - json += '}' - println("${YELLOW}Creating session...${RESET}") - def output = apiRequest('/sessions', 'POST', json, publicKey, secretKey) - def idMatch = output =~ /"id":"([^"]+)"/ - if (idMatch.find()) { - println("${GREEN}Session created: ${idMatch.group(1)}${RESET}") - } else { - println("${GREEN}Session created${RESET}") - } + def output = apiRequest('/sessions', 'POST', payload, publicKey, secretKey) + println("${GREEN}Session created: ${output.id ?: 'unknown'}${RESET}") println("${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}") } @@ -574,13 +1258,13 @@ def cmdSnapshot(args) { if (args.snapshotList) { def output = apiRequest('/snapshots', 'GET', null, publicKey, secretKey) - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.snapshotInfo) { def output = apiRequest("/snapshots/${args.snapshotInfo}", 'GET', null, publicKey, secretKey) - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } @@ -595,20 +1279,13 @@ def cmdSnapshot(args) { System.err.println("${RED}Error: --type required (session or service)${RESET}") System.exit(1) } - def json = "{\"type\":\"${args.snapshotType}\"" - if (args.snapshotName) { - json += ",\"name\":\"${args.snapshotName.replace('\\', '\\\\').replace('"', '\\"')}\"" - } - if (args.snapshotShell) { - json += ",\"shell\":\"${args.snapshotShell}\"" - } - if (args.snapshotPorts) { - json += ",\"ports\":[${args.snapshotPorts}]" - } - json += "}" - def output = apiRequest("/snapshots/${args.snapshotClone}/clone", 'POST', json, publicKey, secretKey) + def payload = [type: args.snapshotType] + if (args.snapshotName) payload.name = args.snapshotName + if (args.snapshotShell) payload.shell = args.snapshotShell + if (args.snapshotPorts) payload.ports = args.snapshotPorts.split(',').collect { it.trim().toInteger() } + def output = apiRequest("/snapshots/${args.snapshotClone}/clone", 'POST', payload, publicKey, secretKey) println("${GREEN}Created from snapshot${RESET}") - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } @@ -622,7 +1299,6 @@ def cmdKey(args) { def curlCmd = ['curl', '-s', '-X', 'POST', "${PORTAL_BASE}/keys/validate", '-H', 'Content-Type: application/json'] - // Add HMAC authentication headers if secretKey is provided if (secretKey) { def timestamp = (System.currentTimeMillis() / 1000) as long def message = "${timestamp}:POST:/keys/validate:{}" @@ -650,28 +1326,20 @@ def cmdKey(args) { System.exit(1) } - def publicKeyMatch = output =~ /"public_key":"([^"]+)"/ - def tierMatch = output =~ /"tier":"([^"]+)"/ - def statusMatch = output =~ /"status":"([^"]+)"/ - def expiresAtMatch = output =~ /"expires_at":"([^"]+)"/ - def timeRemainingMatch = output =~ /"time_remaining":"([^"]+)"/ - def rateLimitMatch = output =~ /"rate_limit":([0-9.]+)/ - def burstMatch = output =~ /"burst":([0-9.]+)/ - def concurrencyMatch = output =~ /"concurrency":([0-9.]+)/ - def expiredMatch = output =~ /"expired":(true|false)/ + def result = new JsonSlurper().parseText(output) - def publicKey = publicKeyMatch.find() ? publicKeyMatch.group(1) : 'N/A' - def tier = tierMatch.find() ? tierMatch.group(1) : 'N/A' - def status = statusMatch.find() ? statusMatch.group(1) : 'N/A' - def expiresAt = expiresAtMatch.find() ? expiresAtMatch.group(1) : 'N/A' - def timeRemaining = timeRemainingMatch.find() ? timeRemainingMatch.group(1) : 'N/A' - def rateLimit = rateLimitMatch.find() ? rateLimitMatch.group(1) : 'N/A' - def burst = burstMatch.find() ? burstMatch.group(1) : 'N/A' - def concurrency = concurrencyMatch.find() ? concurrencyMatch.group(1) : 'N/A' - def expired = expiredMatch.find() ? expiredMatch.group(1) == 'true' : false + def fetchedPublicKey = result.public_key ?: 'N/A' + def tier = result.tier ?: 'N/A' + def status = result.status ?: 'N/A' + def expiresAt = result.expires_at ?: 'N/A' + def timeRemaining = result.time_remaining ?: 'N/A' + def rateLimit = result.rate_limit ?: 'N/A' + def burst = result.burst ?: 'N/A' + def concurrency = result.concurrency ?: 'N/A' + def expired = result.expired ?: false - if (args.keyExtend && publicKey != 'N/A') { - def extendUrl = "${PORTAL_BASE}/keys/extend?pk=${publicKey}" + if (args.keyExtend && fetchedPublicKey != 'N/A') { + def extendUrl = "${PORTAL_BASE}/keys/extend?pk=${fetchedPublicKey}" println("${BLUE}Opening browser to extend key...${RESET}") openBrowser(extendUrl) return @@ -679,7 +1347,7 @@ def cmdKey(args) { if (expired) { println("${RED}Expired${RESET}") - println("Public Key: ${publicKey}") + println("Public Key: ${fetchedPublicKey}") println("Tier: ${tier}") println("Expired: ${expiresAt}") println("${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}") @@ -687,7 +1355,7 @@ def cmdKey(args) { } println("${GREEN}Valid${RESET}") - println("Public Key: ${publicKey}") + println("Public Key: ${fetchedPublicKey}") println("Tier: ${tier}") println("Status: ${status}") println("Expires: ${expiresAt}") @@ -701,57 +1369,54 @@ def cmdService(args) { def (publicKey, secretKey) = getApiKeys(args.apiKey) if (args.serviceSnapshot) { - def json = "{" - if (args.serviceSnapshotName) { - json += "\"name\":\"${args.serviceSnapshotName.replace('\\', '\\\\').replace('"', '\\"')}\"" - } - if (args.serviceHot) { - if (args.serviceSnapshotName) json += "," - json += "\"hot\":true" - } - json += "}" - def output = apiRequest("/services/${args.serviceSnapshot}/snapshot", 'POST', json, publicKey, secretKey) + def payload = [:] + if (args.serviceSnapshotName) payload.name = args.serviceSnapshotName + if (args.serviceHot) payload.hot = true + def output = apiRequest("/services/${args.serviceSnapshot}/snapshot", 'POST', payload, publicKey, secretKey) println("${GREEN}Snapshot created${RESET}") - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.serviceRestore) { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - def output = apiRequest("/snapshots/${args.serviceRestore}/restore", 'POST', '{}', publicKey, secretKey) + def output = apiRequest("/snapshots/${args.serviceRestore}/restore", 'POST', [:], publicKey, secretKey) println("${GREEN}Service restored from snapshot${RESET}") - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.serviceList) { def output = apiRequest('/services', 'GET', null, publicKey, secretKey) - println("%-20s %-15s %-10s %-15s %s".format("ID", "Name", "Status", "Ports", "Domains")) - println("No services (list parsing not implemented)") + def services = output.services ?: [] + if (services.isEmpty()) { + println("No services") + } else { + println(String.format("%-20s %-15s %-10s %-15s %s", "ID", "Name", "Status", "Ports", "Domains")) + services.each { s -> + def ports = (s.ports ?: []).join(',') + def domains = (s.domains ?: []).join(',') + println(String.format("%-20s %-15s %-10s %-15s %s", + s.id ?: '', s.name ?: '', s.status ?: '', ports, domains)) + } + } return } if (args.serviceInfo) { def output = apiRequest("/services/${args.serviceInfo}", 'GET', null, publicKey, secretKey) - println(output) + println(JsonOutput.prettyPrint(JsonOutput.toJson(output))) return } if (args.serviceLogs) { def output = apiRequest("/services/${args.serviceLogs}/logs", 'GET', null, publicKey, secretKey) - def logsMatch = output =~ /"logs":"((?:[^"\\]|\\.)*)"/ - if (logsMatch.find()) { - println(logsMatch.group(1).replace('\\n', '\n')) - } + println(output.logs ?: '') return } if (args.serviceTail) { def output = apiRequest("/services/${args.serviceTail}/logs?lines=9000", 'GET', null, publicKey, secretKey) - def logsMatch = output =~ /"logs":"((?:[^"\\]|\\.)*)"/ - if (logsMatch.find()) { - println(logsMatch.group(1).replace('\\n', '\n')) - } + println(output.logs ?: '') return } @@ -778,55 +1443,29 @@ def cmdService(args) { System.err.println("${RED}Error: --resize requires --vcpu N (1-8)${RESET}") System.exit(1) } - def json = """{"vcpu":${args.vcpu}}""" - apiRequestPatch("/services/${args.serviceResize}", json, publicKey, secretKey) + apiRequestPatch("/services/${args.serviceResize}", [vcpu: args.vcpu], publicKey, secretKey) def ram = args.vcpu * 2 println("${GREEN}Service resized to ${args.vcpu} vCPU, ${ram} GB RAM${RESET}") return } if (args.serviceExecute) { - def json = """{"command":"${args.serviceCommand}"}""" - def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', json, publicKey, secretKey) - def stdoutMatch = output =~ /"stdout":"((?:[^"\\\\]|\\\\.)*)"/ - def stderrMatch = output =~ /"stderr":"((?:[^"\\\\]|\\\\.)*)"/ - - if (stdoutMatch.find()) { - def stdout = stdoutMatch.group(1) - .replace('\\n', '\n') - .replace('\\t', '\t') - .replace('\\"', '"') - .replace('\\\\', '\\') - print("${BLUE}${stdout}${RESET}") - } - - if (stderrMatch.find()) { - def stderr = stderrMatch.group(1) - .replace('\\n', '\n') - .replace('\\t', '\t') - .replace('\\"', '"') - .replace('\\\\', '\\') - System.err.print("${RED}${stderr}${RESET}") - } + def output = apiRequest("/services/${args.serviceExecute}/execute", 'POST', + [command: args.serviceCommand], publicKey, secretKey) + if (output.stdout) print("${BLUE}${output.stdout}${RESET}") + if (output.stderr) System.err.print("${RED}${output.stderr}${RESET}") return } if (args.serviceDumpBootstrap) { System.err.println("Fetching bootstrap script from ${args.serviceDumpBootstrap}...") - def json = """{"command":"cat /tmp/bootstrap.sh"}""" - def output = apiRequest("/services/${args.serviceDumpBootstrap}/execute", 'POST', json, publicKey, secretKey) - - def stdoutMatch = output =~ /"stdout":"((?:[^"\\\\]|\\\\.)*)"/ - if (stdoutMatch.find()) { - def bootstrap = stdoutMatch.group(1) - .replace('\\n', '\n') - .replace('\\t', '\t') - .replace('\\"', '"') - .replace('\\\\', '\\') + def output = apiRequest("/services/${args.serviceDumpBootstrap}/execute", 'POST', + [command: 'cat /tmp/bootstrap.sh'], publicKey, secretKey) + if (output.stdout) { if (args.serviceDumpFile) { try { - new File(args.serviceDumpFile).text = bootstrap + new File(args.serviceDumpFile).text = output.stdout "chmod 755 ${args.serviceDumpFile}".execute().waitFor() println("Bootstrap saved to ${args.serviceDumpFile}") } catch (Exception e) { @@ -834,7 +1473,7 @@ def cmdService(args) { System.exit(1) } } else { - print(bootstrap) + print(output.stdout) } } else { System.err.println("${RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${RESET}") @@ -844,66 +1483,41 @@ def cmdService(args) { } if (args.serviceName) { - def json = """{"name":"${args.serviceName}"""" + def payload = [name: args.serviceName] + if (args.servicePorts) { - def ports = args.servicePorts.split(',').collect { it.trim() }.join(',') - json += ""","ports":[${ports}]""" - } - if (args.serviceType) { - json += ""","service_type":"${args.serviceType}"""" - } - if (args.serviceBootstrap) { - def escaped = args.serviceBootstrap.replace('\\', '\\\\').replace('"', '\\"') - json += ""","bootstrap":"${escaped}"""" + payload.ports = args.servicePorts.split(',').collect { it.trim().toInteger() } } + if (args.serviceType) payload.service_type = args.serviceType + if (args.serviceBootstrap) payload.bootstrap = args.serviceBootstrap if (args.serviceBootstrapFile) { def file = new File(args.serviceBootstrapFile) if (file.exists()) { - def content = file.text.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') - json += ""","bootstrap_content":"${content}"""" + payload.bootstrap_content = file.text } else { System.err.println("${RED}Error: Bootstrap file not found: ${args.serviceBootstrapFile}${RESET}") System.exit(1) } } - if (args.network) { - json += ""","network":"${args.network}"""" - } - if (args.vcpu > 0) { - json += ""","vcpu":${args.vcpu}""" - } + if (args.network) payload.network = args.network + if (args.vcpu > 0) payload.vcpu = args.vcpu - // Add input files if (args.files) { - def filesJson = args.files.collect { filepath -> + payload.input_files = args.files.collect { filepath -> def f = new File(filepath) if (!f.exists()) { System.err.println("${RED}Error: Input file not found: ${filepath}${RESET}") System.exit(1) } - def content = f.bytes.encodeBase64().toString() - return """{"filename":"${f.name}","content_base64":"${content}"}""" - }.join(',') - json += ""","input_files":[${filesJson}]""" + return [filename: f.name, content_base64: f.bytes.encodeBase64().toString()] + } } - json += '}' - - def output = apiRequest('/services', 'POST', json, publicKey, secretKey) - def idMatch = output =~ /"id":"([^"]+)"/ - def serviceId = null - if (idMatch.find()) { - serviceId = idMatch.group(1) - println("${GREEN}Service created: ${serviceId}${RESET}") - } - def nameMatch = output =~ /"name":"([^"]+)"/ - if (nameMatch.find()) { - println("Name: ${nameMatch.group(1)}") - } - def urlMatch = output =~ /"url":"([^"]+)"/ - if (urlMatch.find()) { - println("URL: ${urlMatch.group(1)}") - } + def output = apiRequest('/services', 'POST', payload, publicKey, secretKey) + def serviceId = output.id + println("${GREEN}Service created: ${serviceId ?: 'unknown'}${RESET}") + println("Name: ${output.name ?: ''}") + if (output.url) println("URL: ${output.url}") // Auto-set vault if -e or --env-file provided if (serviceId && (args.svcEnvs || args.svcEnvFile)) { @@ -933,7 +1547,6 @@ def parseArgs(argv) { args.command = 'service' break case 'env': - // service env if (args.command == 'service' && i + 2 < argv.size()) { args.envAction = argv[++i] args.envTarget = argv[++i] @@ -945,10 +1558,17 @@ def parseArgs(argv) { case 'key': args.command = 'key' break + case '-s': + args.inlineLang = argv[++i] + break case '-k': case '--api-key': args.apiKey = argv[++i] break + case '-p': + case '--public-key': + args.apiKey = argv[++i] // For compatibility + break case '-n': case '--network': args.network = argv[++i] @@ -984,10 +1604,11 @@ def parseArgs(argv) { case '--list': if (args.command == 'session') args.sessionList = true else if (args.command == 'service') args.serviceList = true + else if (args.command == 'snapshot') args.snapshotList = true break - case '-s': case '--shell': - args.sessionShell = argv[++i] + if (args.command == 'snapshot') args.snapshotShell = argv[++i] + else args.sessionShell = argv[++i] break case '--kill': args.sessionKill = argv[++i] @@ -1030,10 +1651,6 @@ def parseArgs(argv) { if (args.command == 'snapshot') args.snapshotName = argv[++i] else args.serviceName = argv[++i] break - case '--shell': - if (args.command == 'snapshot') args.snapshotShell = argv[++i] - else args.sessionShell = argv[++i] - break case '--ports': if (args.command == 'snapshot') args.snapshotPorts = argv[++i] else args.servicePorts = argv[++i] @@ -1091,45 +1708,53 @@ def parseArgs(argv) { } def printHelp() { - println '''Usage: groovy un.groovy [options] + println '''unsandbox SDK for Groovy - Execute code in secure sandboxes +https://unsandbox.com | https://api.unsandbox.com/openapi + +Usage: groovy un.groovy [options] + groovy un.groovy -s '' groovy un.groovy session [options] groovy un.groovy service [options] groovy un.groovy service env [options] groovy un.groovy key [options] Execute options: - -e KEY=VALUE Set environment variable - -f FILE Add input file - -a Return artifacts - -o DIR Output directory for artifacts - -n MODE Network mode (zerotrust/semitrusted) - -v N vCPU count (1-8) - -k KEY API key + -s LANG Execute inline code with specified language + -e KEY=VALUE Set environment variable + -f FILE Add input file + -a Return artifacts + -o DIR Output directory for artifacts + -n MODE Network mode (zerotrust/semitrusted) + -v N vCPU count (1-8) + -k KEY API key (legacy) + -p KEY Public key Session options: - --list List active sessions - --shell NAME Shell/REPL to use - --kill ID Terminate session + --list List active sessions + --shell NAME Shell/REPL to use + --kill ID Terminate session + --snapshot ID Create snapshot of session + --restore ID Restore session from snapshot Service options: - --list List services - --name NAME Service name - --ports PORTS Comma-separated ports - --type TYPE Service type (minecraft/mumble/teamspeak/source/tcp/udp) - --bootstrap CMD Bootstrap command - -e KEY=VALUE Set env var in vault (when creating service) - --env-file FILE Load env vars from file - --info ID Get service details - --logs ID Get all logs - --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service - --destroy ID Destroy service - --resize ID Resize service (requires --vcpu N) - --execute ID Execute command in service - --command CMD Command to execute (with --execute) - --dump-bootstrap ID Dump bootstrap script - --dump-file FILE File to save bootstrap (with --dump-bootstrap) + --list List services + --name NAME Service name (creates service) + --ports PORTS Comma-separated ports + --type TYPE Service type + --bootstrap CMD Bootstrap command + -e KEY=VALUE Set env var in vault (when creating) + --env-file FILE Load env vars from file + --info ID Get service details + --logs ID Get all logs + --tail ID Get last 9000 lines + --freeze ID Freeze service + --unfreeze ID Unfreeze service + --destroy ID Destroy service + --resize ID Resize service (requires --vcpu N) + --execute ID Execute command in service + --command CMD Command to execute (with --execute) + --dump-bootstrap ID Dump bootstrap script + --dump-file FILE File to save bootstrap Vault commands: service env status Check vault status @@ -1138,19 +1763,25 @@ Vault commands: service env delete Delete vault Key options: - --extend Open browser to extend key - -k KEY API key to validate + --extend Open browser to extend key + +Library Usage: + import un + def result = un.execute("python", 'print("Hello")') + def client = new un.Client(publicKey: "unsb-pk-...", secretKey: "unsb-sk-...") ''' } -// Main execution +// ============================================================================ +// Main Execution (CLI) +// ============================================================================ + try { def args = parseArgs(this.args as List) if (args.command == 'session') { cmdSession(args) } else if (args.command == 'service') { - // Check for env subcommand if (args.envAction && args.envTarget) { cmdServiceEnv(args) } else { @@ -1160,12 +1791,15 @@ try { cmdSnapshot(args) } else if (args.command == 'key') { cmdKey(args) - } else if (args.sourceFile) { + } else if (args.sourceFile || args.inlineLang) { cmdExecute(args) } else { printHelp() System.exit(1) } +} catch (UnsandboxError e) { + System.err.println("${RED}Error: ${e.message}${RESET}") + System.exit(1) } catch (Exception e) { System.err.println("${RED}Error: ${e.message}${RESET}") System.exit(1) diff --git a/un.js b/un.js index 1dcdac4..69d4e0e 100644 --- a/un.js +++ b/un.js @@ -34,1015 +34,1049 @@ // https://www.timehexon.com // https://www.foxhop.net // https://www.unturf.com/software +// +// unsandbox SDK for JavaScript/Node.js - Execute code in secure sandboxes +// https://unsandbox.com | https://api.unsandbox.com/openapi +// +// Library Usage: +// const un = require('./un.js'); +// const result = await un.execute("javascript", 'console.log("Hello")'); +// const job = await un.executeAsync("javascript", code); +// const result = await un.wait(job.job_id); +// +// CLI Usage: +// node un.js script.js +// node un.js -s javascript 'console.log("Hello")' +// node un.js session --shell node +// +// Authentication (in priority order): +// 1. Function arguments: execute(..., { publicKey, secretKey }) +// 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +// 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) /** - * un.js - Unsandbox CLI Client (JavaScript/Node.js Implementation) + * unsandbox - Secure Code Execution SDK for JavaScript * - * Full-featured CLI matching un.c capabilities: - * - Execute code with env vars, input files, artifacts - * - Interactive sessions with shell/REPL support - * - Persistent services with domains and ports + * @example Simple execution + * const un = require('./un.js'); + * const result = await un.execute("javascript", 'console.log("Hello World")'); + * console.log(result.stdout); * - * Usage: - * un.js [options] - * un.js session [options] - * un.js service [options] + * @example Async execution + * const job = await un.executeAsync("javascript", longRunningCode); + * const result = await un.wait(job.job_id); * - * Requires: UNSANDBOX_API_KEY environment variable + * @example Client class + * const client = new un.Client({ publicKey: "unsb-pk-...", secretKey: "unsb-sk-..." }); + * const result = await client.execute("javascript", code); */ -const fs = require('fs'); -const https = require('https'); -const path = require('path'); -const { exec } = require('child_process'); const crypto = require('crypto'); +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// ============================================================================ +// Configuration +// ============================================================================ const API_BASE = "https://api.unsandbox.com"; const PORTAL_BASE = "https://unsandbox.com"; +const DEFAULT_TIMEOUT = 300000; // 5 minutes in ms +const DEFAULT_TTL = 60; // 1 minute execution limit + +// Polling delays (ms) - exponential backoff matching un.c +const POLL_DELAYS = [300, 450, 700, 900, 650, 1600, 2000]; + +// Extension to language mapping +const EXT_MAP = { + ".py": "python", ".js": "javascript", ".ts": "typescript", + ".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua", + ".sh": "bash", ".go": "go", ".rs": "rust", ".c": "c", + ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", + ".java": "java", ".kt": "kotlin", ".cs": "csharp", ".fs": "fsharp", + ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure", ".scm": "scheme", + ".lisp": "commonlisp", ".erl": "erlang", ".ex": "elixir", ".exs": "elixir", + ".jl": "julia", ".r": "r", ".R": "r", ".cr": "crystal", + ".d": "d", ".nim": "nim", ".zig": "zig", ".v": "v", + ".dart": "dart", ".groovy": "groovy", ".scala": "scala", + ".f90": "fortran", ".f95": "fortran", ".cob": "cobol", + ".pro": "prolog", ".forth": "forth", ".4th": "forth", + ".tcl": "tcl", ".raku": "raku", ".m": "objc", ".awk": "awk", +}; + +// ANSI colors const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; const RESET = "\x1b[0m"; -const EXT_MAP = { - ".py": "python", ".js": "javascript", ".ts": "typescript", - ".rb": "ruby", ".php": "php", ".pl": "perl", ".lua": "lua", - ".sh": "bash", ".go": "go", ".rs": "rust", ".c": "c", - ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", - ".java": "java", ".kt": "kotlin", ".cs": "csharp", ".fs": "fsharp", - ".hs": "haskell", ".ml": "ocaml", ".clj": "clojure", ".scm": "scheme", - ".lisp": "commonlisp", ".erl": "erlang", ".ex": "elixir", ".exs": "elixir", - ".jl": "julia", ".r": "r", ".R": "r", ".cr": "crystal", - ".d": "d", ".nim": "nim", ".zig": "zig", ".v": "v", - ".dart": "dart", ".groovy": "groovy", ".scala": "scala", - ".f90": "fortran", ".f95": "fortran", ".cob": "cobol", - ".pro": "prolog", ".forth": "forth", ".4th": "forth", - ".tcl": "tcl", ".raku": "raku", ".m": "objc", -}; +// ============================================================================ +// Exceptions +// ============================================================================ -function getApiKeys(argsKey) { - // Try new split key format first - let publicKey = process.env.UNSANDBOX_PUBLIC_KEY; - let secretKey = process.env.UNSANDBOX_SECRET_KEY; - - // Fall back to old single key format for backwards compatibility - if (!publicKey || !secretKey) { - const oldKey = argsKey || process.env.UNSANDBOX_API_KEY; - if (oldKey) { - publicKey = oldKey; - secretKey = oldKey; - } else { - console.error(`${RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set${RESET}`); - console.error(`${RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)${RESET}`); - process.exit(1); +class UnsandboxError extends Error { + constructor(message) { + super(message); + this.name = 'UnsandboxError'; } - } - - return { publicKey, secretKey }; } -function detectLanguage(filename) { - const ext = path.extname(filename).toLowerCase(); - const lang = EXT_MAP[ext]; - if (!lang) { - try { - const firstLine = fs.readFileSync(filename, 'utf-8').split('\n')[0]; - if (firstLine.startsWith('#!')) { - if (firstLine.includes('python')) return 'python'; - if (firstLine.includes('node')) return 'javascript'; - if (firstLine.includes('ruby')) return 'ruby'; - if (firstLine.includes('perl')) return 'perl'; - if (firstLine.includes('bash') || firstLine.includes('/sh')) return 'bash'; - if (firstLine.includes('lua')) return 'lua'; - if (firstLine.includes('php')) return 'php'; - } - } catch (e) {} - console.error(`${RED}Error: Cannot detect language for ${filename}${RESET}`); - process.exit(1); - } - return lang; +class AuthenticationError extends UnsandboxError { + constructor(message) { + super(message); + this.name = 'AuthenticationError'; + } } -function apiRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) { - return new Promise((resolve, reject) => { - const url = new URL(API_BASE + endpoint); - - // Prepare body - const body = data ? JSON.stringify(data) : ""; - - // Generate HMAC signature - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`; - const signature = crypto.createHmac('sha256', secretKey) - .update(signatureInput) - .digest('hex'); - - const options = { - hostname: url.hostname, - path: url.pathname + url.search, - method: method, - headers: { - 'Authorization': `Bearer ${publicKey}`, - 'X-Timestamp': timestamp, - 'X-Signature': signature, - 'Content-Type': 'application/json' - }, - timeout: 300000 - }; - - const req = https.request(options, (res) => { - let responseBody = ''; - res.on('data', chunk => responseBody += chunk); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - try { - resolve(JSON.parse(responseBody)); - } catch (e) { - resolve(responseBody); - } - } else { - if (res.statusCode === 401 && responseBody.toLowerCase().includes('timestamp')) { - console.error(`${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}`); - console.error(`${YELLOW}Your computer's clock may have drifted.${RESET}`); - console.error(`${YELLOW}Check your system time and sync with NTP if needed:${RESET}`); - console.error(` Linux: sudo ntpdate -s time.nist.gov`); - console.error(` macOS: sudo sntp -sS time.apple.com`); - console.error(` Windows: w32tm /resync`); - } else { - console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`); - } - process.exit(1); - } - }); - }); - - req.on('error', (e) => { - console.error(`${RED}Error: ${e.message}${RESET}`); - process.exit(1); - }); - - if (data) { - req.write(body); +class ExecutionError extends UnsandboxError { + constructor(message, exitCode = null, stderr = null) { + super(message); + this.name = 'ExecutionError'; + this.exitCode = exitCode; + this.stderr = stderr; } - req.end(); - }); } -function apiRequestText(endpoint, method = "PUT", body = "", publicKey = null, secretKey = null) { - return new Promise((resolve, reject) => { - const url = new URL(API_BASE + endpoint); - - // Generate HMAC signature - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`; - const signature = crypto.createHmac('sha256', secretKey) - .update(signatureInput) - .digest('hex'); - - const options = { - hostname: url.hostname, - path: url.pathname + url.search, - method: method, - headers: { - 'Authorization': `Bearer ${publicKey}`, - 'X-Timestamp': timestamp, - 'X-Signature': signature, - 'Content-Type': 'text/plain' - }, - timeout: 300000 - }; - - const req = https.request(options, (res) => { - let responseBody = ''; - res.on('data', chunk => responseBody += chunk); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - try { - resolve(JSON.parse(responseBody)); - } catch (e) { - resolve(responseBody); - } - } else { - console.error(`${RED}Error: HTTP ${res.statusCode} - ${responseBody}${RESET}`); - resolve(null); - } - }); - }); - - req.on('error', (e) => { - console.error(`${RED}Error: ${e.message}${RESET}`); - resolve(null); - }); - - if (body) { - req.write(body); +class APIError extends UnsandboxError { + constructor(message, statusCode = null, response = null) { + super(message); + this.name = 'APIError'; + this.statusCode = statusCode; + this.response = response; + } +} + +class TimeoutError extends UnsandboxError { + constructor(message) { + super(message); + this.name = 'TimeoutError'; } - req.end(); - }); } // ============================================================================ -// Environment Secrets Vault Functions +// HMAC Authentication // ============================================================================ -const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max env vault size - -async function serviceEnvStatus(publicKey, secretKey, serviceId) { - const result = await apiRequest(`/services/${serviceId}/env`, "GET", null, publicKey, secretKey); - const hasVault = result.has_vault || false; - - if (!hasVault) { - console.log("Vault exists: no"); - console.log("Variable count: 0"); - } else { - console.log("Vault exists: yes"); - console.log(`Variable count: ${result.count || 0}`); - if (result.updated_at) { - const dt = new Date(result.updated_at * 1000); - console.log(`Last updated: ${dt.toISOString().replace('T', ' ').substring(0, 19)}`); - } - } +/** + * Generate HMAC-SHA256 signature for API request. + * Signature = HMAC-SHA256(secretKey, "timestamp:METHOD:path:body") + */ +function signRequest(secretKey, timestamp, method, path, body = "") { + const message = `${timestamp}:${method}:${path}:${body}`; + return crypto.createHmac('sha256', secretKey) + .update(message) + .digest('hex'); } -async function serviceEnvSet(publicKey, secretKey, serviceId, envContent) { - if (!envContent || envContent.length === 0) { - console.error(`${RED}Error: No environment content provided${RESET}`); - return false; - } - - if (envContent.length > MAX_ENV_CONTENT_SIZE) { - console.error(`${RED}Error: Environment content too large (max ${MAX_ENV_CONTENT_SIZE} bytes)${RESET}`); - return false; - } - - const result = await apiRequestText(`/services/${serviceId}/env`, "PUT", envContent, publicKey, secretKey); - if (result === null) { - return false; - } - - const count = result.count !== undefined ? result.count : -1; - if (count >= 0) { - console.log(`${GREEN}Environment vault updated: ${count} variable${count !== 1 ? 's' : ''}${RESET}`); - } else { - console.log(`${GREEN}Environment vault updated${RESET}`); - } - - if (result.message) { - console.log(result.message); - } - - return true; -} - -async function serviceEnvExport(publicKey, secretKey, serviceId) { - const result = await apiRequest(`/services/${serviceId}/env/export`, "POST", {}, publicKey, secretKey); - const envContent = result.env || ""; - if (envContent) { - process.stdout.write(envContent); - if (!envContent.endsWith('\n')) { - console.log(); - } - } -} - -async function serviceEnvDelete(publicKey, secretKey, serviceId) { - await apiRequest(`/services/${serviceId}/env`, "DELETE", null, publicKey, secretKey); - console.log(`${GREEN}Environment vault deleted${RESET}`); -} - -function readEnvFile(filepath) { - try { - return fs.readFileSync(filepath, 'utf-8'); - } catch (e) { - console.error(`${RED}Error: Env file not found: ${filepath}${RESET}`); - process.exit(1); - } -} - -function buildEnvContent(envVars, envFile) { - const parts = []; - - // Read from env file first - if (envFile) { - parts.push(readEnvFile(envFile)); - } - - // Add -e flags (these override/append to file) - if (envVars && envVars.length > 0) { - envVars.forEach(e => { - if (e.includes('=')) { - parts.push(e); - } - }); - } - - return parts.length > 0 ? parts.join('\n') : null; -} - -function portalRequest(endpoint, method = "GET", data = null, publicKey = null, secretKey = null) { - return new Promise((resolve, reject) => { - const url = new URL(PORTAL_BASE + endpoint); - - // Prepare body - const body = data ? JSON.stringify(data) : ""; - - // Generate HMAC signature - const timestamp = Math.floor(Date.now() / 1000).toString(); - const signatureInput = `${timestamp}:${method}:${endpoint}:${body}`; - const signature = crypto.createHmac('sha256', secretKey) - .update(signatureInput) - .digest('hex'); - - const options = { - hostname: url.hostname, - path: url.pathname + url.search, - method: method, - headers: { - 'Authorization': `Bearer ${publicKey}`, - 'X-Timestamp': timestamp, - 'X-Signature': signature, - 'Content-Type': 'application/json' - }, - timeout: 30000 - }; - - const req = https.request(options, (res) => { - let responseBody = ''; - res.on('data', chunk => responseBody += chunk); - res.on('end', () => { - if (res.statusCode >= 200 && res.statusCode < 300) { - try { - resolve(JSON.parse(responseBody)); - } catch (e) { - resolve(responseBody); - } - } else { - try { - const errorBody = JSON.parse(responseBody); - resolve({ error: errorBody.error || responseBody, status: res.statusCode }); - } catch (e) { - resolve({ error: responseBody, status: res.statusCode }); - } - } - }); - }); - - req.on('error', (e) => { - reject(e); - }); - - if (data) { - req.write(body); - } - req.end(); - }); -} - -function openBrowser(url) { - const platform = process.platform; - let command; - - if (platform === 'darwin') { - command = `open "${url}"`; - } else if (platform === 'win32') { - command = `start "${url}"`; - } else { - command = `xdg-open "${url}"`; - } - - exec(command, (error) => { - if (error) { - console.error(`${RED}Error opening browser: ${error.message}${RESET}`); - console.log(`Please visit: ${url}`); - } - }); -} - -async function validateKey(publicKey, secretKey, shouldExtend = false) { - try { - const result = await portalRequest("/keys/validate", "POST", {}, publicKey, secretKey); - - // Handle --extend flag first - if (shouldExtend) { - const public_key = result.public_key; - if (public_key) { - const extendUrl = `${PORTAL_BASE}/keys/extend?pk=${encodeURIComponent(public_key)}`; - console.log(`${BLUE}Opening browser to extend key...${RESET}`); - openBrowser(extendUrl); - return; - } else { - console.error(`${RED}Error: Could not retrieve public key${RESET}`); - process.exit(1); - } - } - - // Check if key is expired - if (result.expired) { - console.log(`${RED}Expired${RESET}`); - console.log(`Public Key: ${result.public_key || 'N/A'}`); - console.log(`Tier: ${result.tier || 'N/A'}`); - console.log(`Expired: ${result.expires_at || 'N/A'}`); - console.log(`${YELLOW}To renew: Visit https://unsandbox.com/keys/extend${RESET}`); - process.exit(1); - } - - // Valid key - console.log(`${GREEN}Valid${RESET}`); - console.log(`Public Key: ${result.public_key || 'N/A'}`); - console.log(`Tier: ${result.tier || 'N/A'}`); - console.log(`Status: ${result.status || 'N/A'}`); - console.log(`Expires: ${result.expires_at || 'N/A'}`); - console.log(`Time Remaining: ${result.time_remaining || 'N/A'}`); - console.log(`Rate Limit: ${result.rate_limit || 'N/A'}`); - console.log(`Burst: ${result.burst || 'N/A'}`); - console.log(`Concurrency: ${result.concurrency || 'N/A'}`); - } catch (error) { - console.error(`${RED}Error validating key: ${error.message}${RESET}`); - process.exit(1); - } -} - -async function cmdKey(args) { - const { publicKey, secretKey } = getApiKeys(args.apiKey); - await validateKey(publicKey, secretKey, args.extend); -} - -async function cmdExecute(args) { - const { publicKey, secretKey } = getApiKeys(args.apiKey); - - let code; - let language; - - // Check for inline mode: -s/--shell specified, or sourceFile doesn't exist - if (args.execShell) { - // Inline mode with specified language - code = args.sourceFile; - language = args.execShell; - } else if (!fs.existsSync(args.sourceFile)) { - // File doesn't exist - treat as inline bash code - code = args.sourceFile; - language = "bash"; - } else { - // Normal file execution +/** + * Load credentials from accounts.csv file. + * @param {string} filepath - Path to accounts.csv + * @param {number} accountIndex - Account index (0-based) + * @returns {Object|null} { publicKey, secretKey } or null + */ +function loadAccountsCsv(filepath, accountIndex = 0) { + if (!fs.existsSync(filepath)) return null; try { - code = fs.readFileSync(args.sourceFile, 'utf-8'); + const lines = fs.readFileSync(filepath, 'utf-8').trim().split('\n'); + const validAccounts = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (trimmed.includes(',')) { + const [pk, sk] = trimmed.split(',', 2); + if (pk.startsWith('unsb-pk-') && sk.startsWith('unsb-sk-')) { + validAccounts.push({ publicKey: pk, secretKey: sk }); + } + } + } + if (validAccounts.length > accountIndex) { + return validAccounts[accountIndex]; + } } catch (e) { - console.error(`${RED}Error: File not found: ${args.sourceFile}${RESET}`); - process.exit(1); + // Ignore file read errors } - language = detectLanguage(args.sourceFile); - } + return null; +} - const payload = { language, code }; +/** + * Get API credentials in priority order: + * 1. Function arguments + * 2. Environment variables + * 3. ~/.unsandbox/accounts.csv + * 4. ./accounts.csv (same directory as this SDK) + */ +function getCredentials(publicKey = null, secretKey = null, accountIndex = 0) { + // Priority 1: Function arguments + if (publicKey && secretKey) { + return { publicKey, secretKey }; + } - if (args.env && args.env.length > 0) { - payload.env = {}; - args.env.forEach(e => { - const idx = e.indexOf('='); - if (idx > 0) { - payload.env[e.substring(0, idx)] = e.substring(idx + 1); - } - }); - } + // Priority 2: Environment variables + const envPk = process.env.UNSANDBOX_PUBLIC_KEY; + const envSk = process.env.UNSANDBOX_SECRET_KEY; + if (envPk && envSk) { + return { publicKey: envPk, secretKey: envSk }; + } - if (args.files && args.files.length > 0) { - payload.input_files = args.files.map(filepath => { - try { - const content = fs.readFileSync(filepath); - return { - filename: path.basename(filepath), - content_base64: content.toString('base64') + // Priority 3: ~/.unsandbox/accounts.csv + const homeAccounts = path.join(os.homedir(), '.unsandbox', 'accounts.csv'); + let result = loadAccountsCsv(homeAccounts, accountIndex); + if (result) return result; + + // Priority 4: ./accounts.csv (same directory as SDK) + const localAccounts = path.join(__dirname, 'accounts.csv'); + result = loadAccountsCsv(localAccounts, accountIndex); + if (result) return result; + + throw new AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " + + "or create ~/.unsandbox/accounts.csv or ./accounts.csv, or pass credentials to function." + ); +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +/** + * Make authenticated API request with HMAC signature. + */ +function apiRequest(endpoint, options = {}) { + return new Promise((resolve, reject) => { + const { + method = "GET", + data = null, + bodyText = null, + contentType = "application/json", + publicKey = null, + secretKey = null, + timeout = DEFAULT_TIMEOUT, + } = options; + + const creds = getCredentials(publicKey, secretKey); + const url = new URL(API_BASE + endpoint); + + // Prepare body + let body = ""; + if (bodyText !== null) { + body = bodyText; + } else if (data !== null) { + body = JSON.stringify(data); + } + + // Generate signature + const timestamp = Math.floor(Date.now() / 1000); + const signature = signRequest(creds.secretKey, timestamp, method, endpoint, body); + + const reqOptions = { + hostname: url.hostname, + port: 443, + path: url.pathname + url.search, + method: method, + headers: { + 'Authorization': `Bearer ${creds.publicKey}`, + 'X-Timestamp': timestamp.toString(), + 'X-Signature': signature, + 'Content-Type': contentType, + }, + timeout: timeout, }; - } catch (e) { - console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); - process.exit(1); - } + + const req = https.request(reqOptions, (res) => { + let responseBody = ''; + res.on('data', chunk => responseBody += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(responseBody ? JSON.parse(responseBody) : {}); + } catch (e) { + resolve(responseBody); + } + } else if (res.statusCode === 401) { + if (responseBody.toLowerCase().includes('timestamp')) { + reject(new AuthenticationError( + "Request timestamp expired. Your system clock may be out of sync. " + + "Run: sudo ntpdate -s time.nist.gov" + )); + } else { + reject(new AuthenticationError(`Authentication failed: ${responseBody}`)); + } + } else if (res.statusCode === 429) { + reject(new APIError(`Rate limit exceeded: ${responseBody}`, res.statusCode, responseBody)); + } else { + reject(new APIError(`HTTP ${res.statusCode}: ${responseBody}`, res.statusCode, responseBody)); + } + }); + }); + + req.on('error', (e) => { + reject(new APIError(`Connection failed: ${e.message}`)); + }); + + req.on('timeout', () => { + req.destroy(); + reject(new TimeoutError('Request timeout')); + }); + + if (body) { + req.write(body); + } + req.end(); }); - } - - if (args.artifacts) payload.return_artifacts = true; - if (args.network) payload.network = args.network; - if (args.vcpu) payload.vcpu = args.vcpu; - - const result = await apiRequest("/execute", "POST", payload, publicKey, secretKey); - - if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); - if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); - - if (args.artifacts && result.artifacts) { - const outDir = args.outputDir || '.'; - if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); - result.artifacts.forEach(artifact => { - const filename = artifact.filename || 'artifact'; - const content = Buffer.from(artifact.content_base64, 'base64'); - const filepath = path.join(outDir, filename); - fs.writeFileSync(filepath, content); - fs.chmodSync(filepath, 0o755); - console.error(`${GREEN}Saved: ${filepath}${RESET}`); - }); - } - - process.exit(result.exit_code || 0); } -async function cmdSession(args) { - const { publicKey, secretKey } = getApiKeys(args.apiKey); +// ============================================================================ +// Core Execution Functions +// ============================================================================ - if (args.list) { - const result = await apiRequest("/sessions", "GET", null, publicKey, secretKey); - const sessions = result.sessions || []; - if (sessions.length === 0) { - console.log("No active sessions"); - } else { - console.log(`${'ID'.padEnd(40)} ${'Shell'.padEnd(10)} ${'Status'.padEnd(10)} Created`); - sessions.forEach(s => { - console.log(`${(s.id || 'N/A').padEnd(40)} ${(s.shell || 'N/A').padEnd(10)} ${(s.status || 'N/A').padEnd(10)} ${s.created_at || 'N/A'}`); - }); +/** + * Execute code synchronously and return results. + * + * @param {string} language - Programming language (python, javascript, go, rust, etc.) + * @param {string} code - Source code to execute + * @param {Object} options - Optional parameters + * @param {Object} options.env - Environment variables dict + * @param {Array} options.inputFiles - List of {filename, content} or {filename, contentBase64} + * @param {string} options.networkMode - "zerotrust" (no network) or "semitrusted" (internet access) + * @param {number} options.ttl - Execution timeout in seconds (1-900, default 60) + * @param {number} options.vcpu - Virtual CPUs (1-8, default 1) + * @param {boolean} options.returnArtifact - Return compiled binary + * @param {boolean} options.returnWasmArtifact - Compile to WebAssembly + * @param {string} options.publicKey - API public key + * @param {string} options.secretKey - API secret key + * @param {number} options.timeout - HTTP request timeout in ms + * @returns {Promise} Result with stdout, stderr, exit_code, etc. + * + * @example + * const result = await un.execute("javascript", 'console.log("Hello World")'); + * console.log(result.stdout); + */ +async function execute(language, code, options = {}) { + const { + env = null, + inputFiles = null, + networkMode = "zerotrust", + ttl = DEFAULT_TTL, + vcpu = 1, + returnArtifact = false, + returnWasmArtifact = false, + publicKey = null, + secretKey = null, + timeout = DEFAULT_TIMEOUT, + } = options; + + const payload = { + language, + code, + network_mode: networkMode, + ttl, + vcpu, + }; + + if (env) payload.env = env; + + if (inputFiles) { + payload.input_files = inputFiles.map(f => { + if (f.contentBase64 || f.content_base64) { + return { filename: f.filename, content_base64: f.contentBase64 || f.content_base64 }; + } else if (f.content) { + return { + filename: f.filename, + content_base64: Buffer.from(f.content).toString('base64') + }; + } + return f; + }); } - return; - } - if (args.kill) { - await apiRequest(`/sessions/${args.kill}`, "DELETE", null, publicKey, secretKey); - console.log(`${GREEN}Session terminated: ${args.kill}${RESET}`); - return; - } + if (returnArtifact) payload.return_artifact = true; + if (returnWasmArtifact) payload.return_wasm_artifact = true; - if (args.attach) { - console.log(`${YELLOW}Attaching to session ${args.attach}...${RESET}`); - console.log(`${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}`); - return; - } - - const payload = { shell: args.shell || "bash" }; - if (args.network) payload.network = args.network; - if (args.vcpu) payload.vcpu = args.vcpu; - if (args.tmux) payload.persistence = "tmux"; - if (args.screen) payload.persistence = "screen"; - if (args.audit) payload.audit = true; - - // Add input files - if (args.files && args.files.length > 0) { - payload.input_files = args.files.map(filepath => { - try { - const content = fs.readFileSync(filepath); - return { - filename: path.basename(filepath), - content_base64: content.toString('base64') - }; - } catch (e) { - console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); - process.exit(1); - } + return apiRequest("/execute", { + method: "POST", + data: payload, + publicKey, + secretKey, + timeout, }); - } - - console.log(`${YELLOW}Creating session...${RESET}`); - const result = await apiRequest("/sessions", "POST", payload, publicKey, secretKey); - console.log(`${GREEN}Session created: ${result.id || 'N/A'}${RESET}`); - console.log(`${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}`); } -async function cmdService(args) { - const { publicKey, secretKey } = getApiKeys(args.apiKey); +/** + * Execute code asynchronously. Returns immediately with job_id for polling. + * + * @param {string} language - Programming language + * @param {string} code - Source code to execute + * @param {Object} options - Same options as execute() + * @returns {Promise} Result with job_id, status ("pending") + * + * @example + * const job = await un.executeAsync("javascript", longRunningCode); + * console.log(`Job submitted: ${job.job_id}`); + * const result = await un.wait(job.job_id); + */ +async function executeAsync(language, code, options = {}) { + const { + env = null, + inputFiles = null, + networkMode = "zerotrust", + ttl = DEFAULT_TTL, + vcpu = 1, + returnArtifact = false, + returnWasmArtifact = false, + publicKey = null, + secretKey = null, + } = options; - // Handle env subcommand: un.js service env - if (args.envSubcommand === 'env') { - const action = args.envAction; - const target = args.envTarget; + const payload = { + language, + code, + network_mode: networkMode, + ttl, + vcpu, + }; - if (!action) { - console.error(`${RED}Error: env action required (status, set, export, delete)${RESET}`); - process.exit(1); - } - if (!target) { - console.error(`${RED}Error: Service ID required for env command${RESET}`); - process.exit(1); + if (env) payload.env = env; + + if (inputFiles) { + payload.input_files = inputFiles.map(f => { + if (f.contentBase64 || f.content_base64) { + return { filename: f.filename, content_base64: f.contentBase64 || f.content_base64 }; + } else if (f.content) { + return { + filename: f.filename, + content_base64: Buffer.from(f.content).toString('base64') + }; + } + return f; + }); } - if (action === 'status') { - await serviceEnvStatus(publicKey, secretKey, target); - return; - } else if (action === 'set') { - let envContent = buildEnvContent(args.env, args.envFile); - if (!envContent) { - console.error(`${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}`); - process.exit(1); - } - await serviceEnvSet(publicKey, secretKey, target, envContent); - return; - } else if (action === 'export') { - await serviceEnvExport(publicKey, secretKey, target); - return; - } else if (action === 'delete') { - await serviceEnvDelete(publicKey, secretKey, target); - return; - } else { - console.error(`${RED}Error: Unknown env action '${action}'. Use: status, set, export, delete${RESET}`); - process.exit(1); + if (returnArtifact) payload.return_artifact = true; + if (returnWasmArtifact) payload.return_wasm_artifact = true; + + return apiRequest("/execute/async", { + method: "POST", + data: payload, + publicKey, + secretKey, + }); +} + +/** + * Execute code with automatic language detection from shebang. + * + * @param {string} code - Source code with shebang (e.g., #!/usr/bin/env node) + * @param {Object} options - Optional parameters + * @returns {Promise} Result with detected_language, stdout, stderr, etc. + * + * @example + * const code = '#!/usr/bin/env node\nconsole.log("Auto-detected!")'; + * const result = await un.run(code); + * console.log(result.detected_language); // "javascript" + */ +async function run(code, options = {}) { + const { + env = null, + networkMode = "zerotrust", + ttl = DEFAULT_TTL, + publicKey = null, + secretKey = null, + timeout = DEFAULT_TIMEOUT, + } = options; + + let endpoint = `/run?ttl=${ttl}&network_mode=${networkMode}`; + if (env) { + endpoint += `&env=${encodeURIComponent(JSON.stringify(env))}`; } - } - if (args.list) { - const result = await apiRequest("/services", "GET", null, publicKey, secretKey); - const services = result.services || []; - if (services.length === 0) { - console.log("No services"); - } else { - console.log(`${'ID'.padEnd(20)} ${'Name'.padEnd(15)} ${'Status'.padEnd(10)} ${'Ports'.padEnd(15)} Domains`); - services.forEach(s => { - const ports = (s.ports || []).join(','); - const domains = (s.domains || []).join(','); - console.log(`${(s.id || 'N/A').padEnd(20)} ${(s.name || 'N/A').padEnd(15)} ${(s.status || 'N/A').padEnd(10)} ${ports.padEnd(15)} ${domains}`); - }); + return apiRequest(endpoint, { + method: "POST", + bodyText: code, + contentType: "text/plain", + publicKey, + secretKey, + timeout, + }); +} + +/** + * Execute code asynchronously with automatic language detection. + * + * @param {string} code - Source code with shebang + * @param {Object} options - Optional parameters + * @returns {Promise} Result with job_id, detected_language, status ("pending") + */ +async function runAsync(code, options = {}) { + const { + env = null, + networkMode = "zerotrust", + ttl = DEFAULT_TTL, + publicKey = null, + secretKey = null, + } = options; + + let endpoint = `/run/async?ttl=${ttl}&network_mode=${networkMode}`; + if (env) { + endpoint += `&env=${encodeURIComponent(JSON.stringify(env))}`; } - return; - } - if (args.info) { - const result = await apiRequest(`/services/${args.info}`, "GET", null, publicKey, secretKey); - console.log(JSON.stringify(result, null, 2)); - return; - } + return apiRequest(endpoint, { + method: "POST", + bodyText: code, + contentType: "text/plain", + publicKey, + secretKey, + }); +} - if (args.logs) { - const result = await apiRequest(`/services/${args.logs}/logs`, "GET", null, publicKey, secretKey); - console.log(result.logs || ""); - return; - } +// ============================================================================ +// Job Management +// ============================================================================ - if (args.tail) { - const result = await apiRequest(`/services/${args.tail}/logs?lines=9000`, "GET", null, publicKey, secretKey); - console.log(result.logs || ""); - return; - } +/** + * Get job status and results. + * + * @param {string} jobId - Job ID from executeAsync or runAsync + * @param {Object} options - Optional parameters + * @returns {Promise} Job status with keys: job_id, status, result (if completed) + */ +async function getJob(jobId, options = {}) { + const { publicKey = null, secretKey = null } = options; + return apiRequest(`/jobs/${jobId}`, { + method: "GET", + publicKey, + secretKey, + }); +} - if (args.sleep) { - await apiRequest(`/services/${args.sleep}/freeze`, "POST", null, publicKey, secretKey); - console.log(`${GREEN}Service frozen: ${args.sleep}${RESET}`); - return; - } +/** + * Wait for job completion with exponential backoff polling. + * + * @param {string} jobId - Job ID from executeAsync or runAsync + * @param {Object} options - Optional parameters + * @param {number} options.maxPolls - Maximum number of poll attempts (default 100) + * @returns {Promise} Final job result + * + * @example + * const job = await un.executeAsync("javascript", code); + * const result = await un.wait(job.job_id); + * console.log(result.stdout); + */ +async function wait(jobId, options = {}) { + const { + maxPolls = 100, + publicKey = null, + secretKey = null, + } = options; - if (args.wake) { - await apiRequest(`/services/${args.wake}/unfreeze`, "POST", null, publicKey, secretKey); - console.log(`${GREEN}Service unfreezing: ${args.wake}${RESET}`); - return; - } + const terminalStates = new Set(['completed', 'failed', 'timeout', 'cancelled']); - if (args.destroy) { - await apiRequest(`/services/${args.destroy}`, "DELETE", null, publicKey, secretKey); - console.log(`${GREEN}Service destroyed: ${args.destroy}${RESET}`); - return; - } + for (let i = 0; i < maxPolls; i++) { + // Exponential backoff delay + const delayIdx = Math.min(i, POLL_DELAYS.length - 1); + await new Promise(resolve => setTimeout(resolve, POLL_DELAYS[delayIdx])); - if (args.resize) { - if (!args.vcpu) { - console.error(`${RED}Error: --vcpu required with --resize${RESET}`); - process.exit(1); + const result = await getJob(jobId, { publicKey, secretKey }); + const status = result.status || ""; + + if (terminalStates.has(status)) { + if (status === 'failed') { + throw new ExecutionError( + `Job failed: ${result.error || 'Unknown error'}`, + result.exit_code, + result.stderr + ); + } + if (status === 'timeout') { + throw new TimeoutError(`Job timed out: ${jobId}`); + } + return result; + } } - const payload = { vcpu: args.vcpu }; - await apiRequest(`/services/${args.resize}`, "PATCH", payload, publicKey, secretKey); - console.log(`${GREEN}Service resized to ${args.vcpu} vCPU, ${args.vcpu * 2}GB RAM${RESET}`); - return; - } - if (args.execute) { - const payload = { command: args.command }; - const result = await apiRequest(`/services/${args.execute}/execute`, "POST", payload, publicKey, secretKey); - if (result.stdout) process.stdout.write(`${BLUE}${result.stdout}${RESET}`); - if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); - return; - } + throw new TimeoutError(`Max polls (${maxPolls}) exceeded for job ${jobId}`); +} - if (args.dumpBootstrap) { - console.error(`Fetching bootstrap script from ${args.dumpBootstrap}...`); - const payload = { command: "cat /tmp/bootstrap.sh" }; - const result = await apiRequest(`/services/${args.dumpBootstrap}/execute`, "POST", payload, publicKey, secretKey); +/** + * Cancel a running job. + * + * @param {string} jobId - Job ID to cancel + * @param {Object} options - Optional parameters + * @returns {Promise} Partial output and artifacts collected before cancellation + */ +async function cancelJob(jobId, options = {}) { + const { publicKey = null, secretKey = null } = options; + return apiRequest(`/jobs/${jobId}`, { + method: "DELETE", + publicKey, + secretKey, + }); +} - if (result.stdout) { - const bootstrap = result.stdout; - if (args.dumpFile) { - // Write to file +/** + * List all active jobs for this API key. + * + * @param {Object} options - Optional parameters + * @returns {Promise} List of job summaries + */ +async function listJobs(options = {}) { + const { publicKey = null, secretKey = null } = options; + const result = await apiRequest("/jobs", { + method: "GET", + publicKey, + secretKey, + }); + return result.jobs || []; +} + +// ============================================================================ +// Image Generation +// ============================================================================ + +/** + * Generate images from text prompt. + * + * @param {string} prompt - Text description of the image to generate + * @param {Object} options - Optional parameters + * @param {string} options.model - Model to use (optional) + * @param {string} options.size - Image size (e.g., "1024x1024") + * @param {string} options.quality - "standard" or "hd" + * @param {number} options.n - Number of images to generate + * @returns {Promise} Result with images array + * + * @example + * const result = await un.image("A sunset over mountains"); + * console.log(result.images[0]); + */ +async function image(prompt, options = {}) { + const { + model = null, + size = "1024x1024", + quality = "standard", + n = 1, + publicKey = null, + secretKey = null, + } = options; + + const payload = { prompt, size, quality, n }; + if (model) payload.model = model; + + return apiRequest("/image", { + method: "POST", + data: payload, + publicKey, + secretKey, + }); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Get list of supported programming languages. + * + * Results are cached in ~/.unsandbox/languages.json for 1 hour. + * + * @param {Object} options - Optional parameters + * @param {boolean} options.forceRefresh - Bypass cache and fetch fresh data + * @returns {Promise} Result with languages array, count, aliases + */ +async function languages(options = {}) { + const { publicKey = null, secretKey = null, forceRefresh = false } = options; + const cachePath = path.join(os.homedir(), '.unsandbox', 'languages.json'); + const cacheMaxAge = 3600 * 1000; // 1 hour in ms + + // Check cache unless force refresh + if (!forceRefresh && fs.existsSync(cachePath)) { try { - fs.writeFileSync(args.dumpFile, bootstrap); - fs.chmodSync(args.dumpFile, 0o755); - console.log(`Bootstrap saved to ${args.dumpFile}`); + const stat = fs.statSync(cachePath); + if (Date.now() - stat.mtimeMs < cacheMaxAge) { + return JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + } } catch (e) { - console.error(`${RED}Error: Could not write to ${args.dumpFile}: ${e.message}${RESET}`); - process.exit(1); + // Cache read failed, fetch from API } - } else { - // Print to stdout - process.stdout.write(bootstrap); - } - } else { - console.error(`${RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${RESET}`); - process.exit(1); } - return; - } - if (args.name) { - const payload = { name: args.name }; - if (args.ports) payload.ports = args.ports.split(',').map(p => parseInt(p.trim())); - if (args.domains) payload.domains = args.domains.split(','); - if (args.serviceType) payload.service_type = args.serviceType; - if (args.bootstrap) { - payload.bootstrap = args.bootstrap; - } - if (args.bootstrapFile) { - if (!fs.existsSync(args.bootstrapFile)) { - console.error(`${RED}Error: Bootstrap file not found: ${args.bootstrapFile}${RESET}`); - process.exit(1); - } - payload.bootstrap_content = fs.readFileSync(args.bootstrapFile, 'utf-8'); - } - // Add input files - if (args.files && args.files.length > 0) { - payload.input_files = args.files.map(filepath => { - try { - const content = fs.readFileSync(filepath); - return { - filename: path.basename(filepath), - content_base64: content.toString('base64') - }; - } catch (e) { - console.error(`${RED}Error: Input file not found: ${filepath}${RESET}`); - process.exit(1); + // Fetch from API + const result = await apiRequest("/languages", { + method: "GET", + publicKey, + secretKey, + }); + + // Save to cache + try { + const cacheDir = path.dirname(cachePath); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); } - }); + fs.writeFileSync(cachePath, JSON.stringify(result)); + } catch (e) { + // Cache write failed, continue anyway } - if (args.network) payload.network = args.network; - if (args.vcpu) payload.vcpu = args.vcpu; - const result = await apiRequest("/services", "POST", payload, publicKey, secretKey); - const createdId = result.id; - console.log(`${GREEN}Service created: ${createdId || 'N/A'}${RESET}`); - console.log(`Name: ${result.name || 'N/A'}`); - if (result.url) console.log(`URL: ${result.url}`); - - // Set environment vault if -e or --env-file provided - if (createdId) { - const envContent = buildEnvContent(args.env, args.envFile); - if (envContent) { - console.error(`${YELLOW}Setting environment vault...${RESET}`); - if (!await serviceEnvSet(publicKey, secretKey, createdId, envContent)) { - console.error(`${YELLOW}Warning: Failed to set environment vault${RESET}`); - } - } - } - return; - } - - console.error(`${RED}Error: Specify --name to create a service, or use --list, --info, etc.${RESET}`); - process.exit(1); + return result; } -function parseArgs(argv) { - const args = { - command: null, - sourceFile: null, - env: [], - files: [], - artifacts: false, - outputDir: null, - network: null, - vcpu: null, - apiKey: null, - shell: null, - list: false, - attach: null, - kill: null, - audit: false, - tmux: false, - screen: false, - name: null, - ports: null, - domains: null, - bootstrap: null, - info: null, - logs: null, - tail: null, - sleep: null, - wake: null, - destroy: null, - resize: null, - execute: null, - command_arg: null, - dumpBootstrap: null, - dumpFile: null, - extend: false, - execShell: null, - envFile: null, - envSubcommand: null, - envAction: null, - envTarget: null, - }; +/** + * Detect programming language from file extension or shebang. + * + * @param {string} filename - File path + * @returns {string|null} Language name or null if undetected + */ +function detectLanguage(filename) { + const ext = path.extname(filename).toLowerCase(); + if (EXT_MAP[ext]) return EXT_MAP[ext]; - let i = 2; - while (i < argv.length) { - const arg = argv[i]; - - if (arg === 'session' || arg === 'service' || arg === 'key') { - args.command = arg; - i++; - // Check for env subcommand: service env - if (arg === 'service' && i < argv.length && argv[i] === 'env') { - args.envSubcommand = 'env'; - i++; - if (i < argv.length && !argv[i].startsWith('-')) { - args.envAction = argv[i]; - i++; + // Try shebang + try { + const content = fs.readFileSync(filename, 'utf-8'); + const firstLine = content.split('\n')[0]; + if (firstLine.startsWith('#!')) { + if (firstLine.includes('python')) return 'python'; + if (firstLine.includes('node')) return 'javascript'; + if (firstLine.includes('ruby')) return 'ruby'; + if (firstLine.includes('perl')) return 'perl'; + if (firstLine.includes('bash') || firstLine.includes('/sh')) return 'bash'; + if (firstLine.includes('lua')) return 'lua'; + if (firstLine.includes('php')) return 'php'; } - if (i < argv.length && !argv[i].startsWith('-')) { - args.envTarget = argv[i]; - i++; - } - } - } else if (arg === '-e' && i + 1 < argv.length) { - args.env.push(argv[++i]); - i++; - } else if (arg === '-f' && i + 1 < argv.length) { - args.files.push(argv[++i]); - i++; - } else if (arg === '-a') { - args.artifacts = true; - i++; - } else if (arg === '-o' && i + 1 < argv.length) { - args.outputDir = argv[++i]; - i++; - } else if (arg === '-n' && i + 1 < argv.length) { - args.network = argv[++i]; - i++; - } else if (arg === '-v' && i + 1 < argv.length) { - args.vcpu = parseInt(argv[++i]); - i++; - } else if (arg === '-k' && i + 1 < argv.length) { - args.apiKey = argv[++i]; - i++; - } else if (arg === '-s' || arg === '--shell') { - // For session command, this is shell type. For execute, it's inline exec language. - if (args.command === 'session') { - args.shell = argv[++i]; - } else { - args.execShell = argv[++i]; - } - i++; - } else if (arg === '-l' || arg === '--list') { - args.list = true; - i++; - } else if (arg === '--attach' && i + 1 < argv.length) { - args.attach = argv[++i]; - i++; - } else if (arg === '--kill' && i + 1 < argv.length) { - args.kill = argv[++i]; - i++; - } else if (arg === '--audit') { - args.audit = true; - i++; - } else if (arg === '--tmux') { - args.tmux = true; - i++; - } else if (arg === '--screen') { - args.screen = true; - i++; - } else if (arg === '--name' && i + 1 < argv.length) { - args.name = argv[++i]; - i++; - } else if (arg === '--ports' && i + 1 < argv.length) { - args.ports = argv[++i]; - i++; - } else if (arg === '--domains' && i + 1 < argv.length) { - args.domains = argv[++i]; - i++; - } else if (arg === '--type' && i + 1 < argv.length) { - args.serviceType = argv[++i]; - i++; - } else if (arg === '--bootstrap' && i + 1 < argv.length) { - args.bootstrap = argv[++i]; - i++; - } else if (arg === '--bootstrap-file' && i + 1 < argv.length) { - args.bootstrapFile = argv[++i]; - i++; - } else if (arg === '--info' && i + 1 < argv.length) { - args.info = argv[++i]; - i++; - } else if (arg === '--logs' && i + 1 < argv.length) { - args.logs = argv[++i]; - i++; - } else if (arg === '--tail' && i + 1 < argv.length) { - args.tail = argv[++i]; - i++; - } else if (arg === '--freeze' && i + 1 < argv.length) { - args.sleep = argv[++i]; - i++; - } else if (arg === '--unfreeze' && i + 1 < argv.length) { - args.wake = argv[++i]; - i++; - } else if (arg === '--destroy' && i + 1 < argv.length) { - args.destroy = argv[++i]; - i++; - } else if (arg === '--resize' && i + 1 < argv.length) { - args.resize = argv[++i]; - i++; - } else if (arg === '--execute' && i + 1 < argv.length) { - args.execute = argv[++i]; - i++; - } else if (arg === '--command' && i + 1 < argv.length) { - args.command_arg = argv[++i]; - i++; - } else if (arg === '--dump-bootstrap' && i + 1 < argv.length) { - args.dumpBootstrap = argv[++i]; - i++; - } else if (arg === '--dump-file' && i + 1 < argv.length) { - args.dumpFile = argv[++i]; - i++; - } else if (arg === '--env-file' && i + 1 < argv.length) { - args.envFile = argv[++i]; - i++; - } else if (arg === '--extend') { - args.extend = true; - i++; - } else if (!arg.startsWith('-')) { - args.sourceFile = arg; - i++; - } else { - console.error(`${RED}Unknown option: ${arg}${RESET}`); - process.exit(1); + } catch (e) { + // File read failed } - } - return args; + return null; } -async function main() { - const args = parseArgs(process.argv); +// ============================================================================ +// Client Class +// ============================================================================ - if (args.command === 'session') { - await cmdSession(args); - } else if (args.command === 'service') { - await cmdService(args); - } else if (args.command === 'key') { - await cmdKey(args); - } else if (args.sourceFile) { - await cmdExecute(args); - } else { - console.log(`Unsandbox CLI - Execute code in secure sandboxes +/** + * Unsandbox API client with stored credentials. + * + * @example + * const client = new un.Client({ publicKey: "unsb-pk-...", secretKey: "unsb-sk-..." }); + * const result = await client.execute("javascript", 'console.log("Hello")'); + * + * // Or load from environment/config automatically: + * const client = new un.Client(); + * const result = await client.execute("javascript", code); + */ +class Client { + /** + * Initialize client with credentials. + * + * @param {Object} options - Optional parameters + * @param {string} options.publicKey - API public key (unsb-pk-...) + * @param {string} options.secretKey - API secret key (unsb-sk-...) + * @param {number} options.accountIndex - Account index in ~/.unsandbox/accounts.csv + */ + constructor(options = {}) { + const { publicKey = null, secretKey = null, accountIndex = 0 } = options; + const creds = getCredentials(publicKey, secretKey, accountIndex); + this.publicKey = creds.publicKey; + this.secretKey = creds.secretKey; + } + + async execute(language, code, options = {}) { + return execute(language, code, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async executeAsync(language, code, options = {}) { + return executeAsync(language, code, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async run(code, options = {}) { + return run(code, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async runAsync(code, options = {}) { + return runAsync(code, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async getJob(jobId) { + return getJob(jobId, { publicKey: this.publicKey, secretKey: this.secretKey }); + } + + async wait(jobId, options = {}) { + return wait(jobId, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async cancelJob(jobId) { + return cancelJob(jobId, { publicKey: this.publicKey, secretKey: this.secretKey }); + } + + async listJobs() { + return listJobs({ publicKey: this.publicKey, secretKey: this.secretKey }); + } + + async image(prompt, options = {}) { + return image(prompt, { + ...options, + publicKey: this.publicKey, + secretKey: this.secretKey, + }); + } + + async languages(options = {}) { + return languages({ ...options, publicKey: this.publicKey, secretKey: this.secretKey }); + } +} + +// ============================================================================ +// CLI Interface +// ============================================================================ + +async function cliMain() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + console.log(`unsandbox - Execute code in secure sandboxes Usage: - ${process.argv[1]} [options] - ${process.argv[1]} session [options] - ${process.argv[1]} service [options] - ${process.argv[1]} key [options] + node un.js [options] + node un.js -s '' -Execute options: - -e KEY=VALUE Environment variable (multiple allowed) - -f FILE Input file (multiple allowed) - -a Return artifacts - -o DIR Output directory for artifacts - -n MODE Network mode (zerotrust|semitrusted) - -v N vCPU count (1-8) - -k KEY API key +Options: + -s, --shell LANG Execute inline code with specified language + -e KEY=VALUE Set environment variable (multiple allowed) + -f FILE Add input file (multiple allowed) + -n MODE Network mode: zerotrust (default) or semitrusted + -v N vCPU count (1-8, default 1) + --ttl N Execution timeout in seconds (default 60) + -a, --artifacts Return artifacts + -o DIR Output directory for artifacts + -p KEY API public key + -k KEY API secret key + --async Execute asynchronously -Session options: - -s, --shell NAME Shell/REPL (default: bash) - -l, --list List sessions - --attach ID Attach to session - --kill ID Terminate session - --audit Record session - --tmux Enable tmux persistence - --screen Enable screen persistence - -Service options: - --name NAME Service name - --ports PORTS Comma-separated ports - --domains DOMAINS Custom domains - --type TYPE Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp) - --bootstrap CMD Bootstrap command or URI - --bootstrap-file FILE Upload local file as bootstrap script - -l, --list List services - --info ID Get service details - --logs ID Get all logs - --tail ID Get last 9000 lines - --freeze ID Freeze service - --unfreeze ID Unfreeze service - --destroy ID Destroy service - --resize ID Resize service (requires -v) - --execute ID Execute command in service - --command CMD Command to execute (with --execute) - --dump-bootstrap ID Dump bootstrap script - --dump-file FILE File to save bootstrap (with --dump-bootstrap) - -Key options: - --extend Open browser to extend key expiration - -k KEY API key to validate +Examples: + node un.js script.js Execute JavaScript file + node un.js -s python 'print("Hello")' Execute inline Python + node un.js -e DEBUG=1 script.js With environment variable + node un.js -n semitrusted script.js With network access `); - process.exit(1); - } + process.exit(args.length === 0 ? 1 : 0); + } + + // Parse arguments + let source = null; + let inlineLang = null; + const env = {}; + const files = []; + let networkMode = "zerotrust"; + let vcpu = 1; + let ttl = 60; + let artifacts = false; + let outputDir = null; + let publicKey = null; + let secretKey = null; + let asyncMode = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '-s' || arg === '--shell') { + inlineLang = args[++i]; + } else if (arg === '-e') { + const [k, v] = args[++i].split('=', 2); + env[k] = v || ''; + } else if (arg === '-f') { + files.push(args[++i]); + } else if (arg === '-n' || arg === '--network') { + networkMode = args[++i]; + } else if (arg === '-v' || arg === '--vcpu') { + vcpu = parseInt(args[++i]); + } else if (arg === '--ttl') { + ttl = parseInt(args[++i]); + } else if (arg === '-a' || arg === '--artifacts') { + artifacts = true; + } else if (arg === '-o' || arg === '--output') { + outputDir = args[++i]; + } else if (arg === '-p' || arg === '--public-key') { + publicKey = args[++i]; + } else if (arg === '-k' || arg === '--secret-key') { + secretKey = args[++i]; + } else if (arg === '--async') { + asyncMode = true; + } else if (!arg.startsWith('-')) { + source = arg; + } + } + + try { + // Determine language and code + let language, code; + if (inlineLang) { + language = inlineLang; + code = source || ""; + } else if (!source) { + console.error(`${RED}Error: No source file or code provided${RESET}`); + process.exit(1); + } else if (!fs.existsSync(source)) { + // Treat as inline bash + language = "bash"; + code = source; + } else { + language = detectLanguage(source); + if (!language) { + console.error(`${RED}Error: Cannot detect language for ${source}${RESET}`); + process.exit(1); + } + code = fs.readFileSync(source, 'utf-8'); + } + + // Load input files + const inputFiles = files.map(filepath => { + if (!fs.existsSync(filepath)) { + console.error(`${RED}Error: File not found: ${filepath}${RESET}`); + process.exit(1); + } + return { + filename: path.basename(filepath), + contentBase64: fs.readFileSync(filepath).toString('base64') + }; + }); + + // Execute + if (asyncMode) { + const result = await executeAsync(language, code, { + env: Object.keys(env).length ? env : null, + inputFiles: inputFiles.length ? inputFiles : null, + networkMode, + ttl, + vcpu, + returnArtifact: artifacts, + publicKey, + secretKey, + }); + console.log(`${GREEN}Job submitted: ${result.job_id}${RESET}`); + console.log(`Status: ${result.status}`); + console.log(`\nPoll with: node un.js job ${result.job_id}`); + } else { + const result = await execute(language, code, { + env: Object.keys(env).length ? env : null, + inputFiles: inputFiles.length ? inputFiles : null, + networkMode, + ttl, + vcpu, + returnArtifact: artifacts, + publicKey, + secretKey, + }); + + // Print output + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(`${RED}${result.stderr}${RESET}`); + + // Save artifacts + if (artifacts && result.artifacts) { + const outDir = outputDir || '.'; + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + for (const artifact of result.artifacts) { + const filename = artifact.filename || 'artifact'; + const content = Buffer.from(artifact.content_base64, 'base64'); + const filepath = path.join(outDir, filename); + fs.writeFileSync(filepath, content); + fs.chmodSync(filepath, 0o755); + console.error(`${GREEN}Saved: ${filepath}${RESET}`); + } + } + + process.exit(result.exit_code || 0); + } + } catch (e) { + if (e instanceof AuthenticationError) { + console.error(`${RED}Authentication error: ${e.message}${RESET}`); + } else if (e instanceof ExecutionError) { + console.error(`${RED}Execution error: ${e.message}${RESET}`); + if (e.stderr) console.error(`${RED}${e.stderr}${RESET}`); + } else if (e instanceof APIError) { + console.error(`${RED}API error: ${e.message}${RESET}`); + } else if (e instanceof TimeoutError) { + console.error(`${RED}Timeout: ${e.message}${RESET}`); + process.exit(124); + } else { + console.error(`${RED}Error: ${e.message}${RESET}`); + } + process.exit(1); + } } -main().catch(err => { - console.error(`${RED}${err}${RESET}`); - process.exit(1); -}); +// ============================================================================ +// Module Exports +// ============================================================================ + +module.exports = { + // Core execution + execute, + executeAsync, + run, + runAsync, + + // Job management + getJob, + wait, + cancelJob, + listJobs, + + // Image generation + image, + + // Utilities + languages, + detectLanguage, + + // Client class + Client, + + // Exceptions + UnsandboxError, + AuthenticationError, + ExecutionError, + APIError, + TimeoutError, + + // Constants + API_BASE, + PORTAL_BASE, + EXT_MAP, + + // Internal functions (for testing/library usage) + _signRequest: signRequest, + _getCredentials: getCredentials, + _apiRequest: apiRequest, +}; + +// Run CLI if called directly +if (require.main === module) { + cliMain().catch(e => { + console.error(`${RED}${e.message}${RESET}`); + process.exit(1); + }); +} diff --git a/un.m b/un.m index c2db71b..00c345a 100644 --- a/un.m +++ b/un.m @@ -33,25 +33,60 @@ // https://www.timehexon.com // https://www.foxhop.net // https://www.unturf.com/software - +// +// unsandbox SDK for Objective-C - Execute code in secure sandboxes +// https://unsandbox.com | https://api.unsandbox.com/openapi +// +// Library Usage: +// #import "un.m" // or as header +// UNClient *client = [[UNClient alloc] init]; +// NSDictionary *result = [client execute:@"python" code:@"print('Hello')"]; +// NSLog(@"%@", result[@"stdout"]); +// +// CLI Usage: +// ./un.m script.py +// ./un.m -s python 'print("Hello")' +// ./un.m session --shell python3 +// +// Authentication (in priority order): +// 1. UNClient initWithPublicKey:secretKey: constructor arguments +// 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +// 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) #!/usr/bin/env -S clang -x objective-c -framework Foundation -o /tmp/un_objc && /tmp/un_objc -// unsandbox CLI - Objective-C implementation -// Full-featured CLI matching un.c/un.py capabilities - #import #import -static NSString* API_BASE = @"https://api.unsandbox.com"; -static NSString* PORTAL_BASE = @"https://unsandbox.com"; -static NSString* BLUE = @"\033[34m"; -static NSString* RED = @"\033[31m"; -static NSString* GREEN = @"\033[32m"; -static NSString* YELLOW = @"\033[33m"; -static NSString* RESET = @"\033[0m"; +// ============================================================================ +// Configuration +// ============================================================================ -NSDictionary* getExtMap() { +static NSString* const UN_API_BASE = @"https://api.unsandbox.com"; +static NSString* const UN_PORTAL_BASE = @"https://unsandbox.com"; +static const NSInteger UN_DEFAULT_TIMEOUT = 300; +static const NSInteger UN_DEFAULT_TTL = 60; +static const NSInteger UN_LANGUAGES_CACHE_TTL = 3600; // 1 hour in seconds + +// Polling delays (ms) - exponential backoff +static const int UN_POLL_DELAYS[] = {300, 450, 700, 900, 650, 1600, 2000}; +static const int UN_POLL_DELAYS_COUNT = 7; + +// ANSI colors +static NSString* const BLUE = @"\033[34m"; +static NSString* const RED = @"\033[31m"; +static NSString* const GREEN = @"\033[32m"; +static NSString* const YELLOW = @"\033[33m"; +static NSString* const RESET = @"\033[0m"; + +// ============================================================================ +// Extension to Language Mapping +// ============================================================================ + +/** + * Returns mapping from file extensions to language identifiers. + */ +NSDictionary* UNGetExtMap(void) { return @{ @"py": @"python", @"js": @"javascript", @"ts": @"typescript", @"rb": @"ruby", @"php": @"php", @"pl": @"perl", @"lua": @"lua", @@ -66,53 +101,79 @@ NSDictionary* getExtMap() { @"f90": @"fortran", @"f95": @"fortran", @"cob": @"cobol", @"pro": @"prolog", @"forth": @"forth", @"4th": @"forth", @"tcl": @"tcl", @"raku": @"raku", @"pl6": @"raku", @"p6": @"raku", - @"m": @"objc" + @"m": @"objc", @"awk": @"awk" }; } -void getApiKeys(NSString** publicKey, NSString** secretKey) { - // Try new-style keys first - *publicKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_PUBLIC_KEY"]; - *secretKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_SECRET_KEY"]; +// ============================================================================ +// Error Classes +// ============================================================================ - // Fall back to old-style single key - if (!*publicKey || [*publicKey length] == 0) { - NSString* oldKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_API_KEY"]; - if (!oldKey) { - fprintf(stderr, "%sError: UNSANDBOX_PUBLIC_KEY/UNSANDBOX_SECRET_KEY or UNSANDBOX_API_KEY not set%s\n", - [RED UTF8String], [RESET UTF8String]); - exit(1); - } - *publicKey = oldKey; - *secretKey = oldKey; - return; - } +/** + * UNError - Base error class for unsandbox SDK errors. + */ +@interface UNError : NSError ++ (instancetype)errorWithMessage:(NSString*)message; +@end - if (!*secretKey || [*secretKey length] == 0) { - fprintf(stderr, "%sError: UNSANDBOX_SECRET_KEY not set%s\n", [RED UTF8String], [RESET UTF8String]); - exit(1); - } +@implementation UNError ++ (instancetype)errorWithMessage:(NSString*)message { + return [self errorWithDomain:@"com.unsandbox" code:1 userInfo:@{NSLocalizedDescriptionKey: message}]; } +@end -void checkClockDrift(NSString* response) { - NSString* responseLower = [response lowercaseString]; - if ([responseLower rangeOfString:@"timestamp"].location != NSNotFound && - ([responseLower rangeOfString:@"401"].location != NSNotFound || - [responseLower rangeOfString:@"expired"].location != NSNotFound || - [responseLower rangeOfString:@"invalid"].location != NSNotFound)) { - fprintf(stderr, "%sError: Request timestamp expired (must be within 5 minutes of server time)%s\n", - [RED UTF8String], [RESET UTF8String]); - fprintf(stderr, "%sYour computer's clock may have drifted.%s\n", - [YELLOW UTF8String], [RESET UTF8String]); - fprintf(stderr, "Check your system time and sync with NTP if needed:\n"); - fprintf(stderr, " Linux: sudo ntpdate -s time.nist.gov\n"); - fprintf(stderr, " macOS: sudo sntp -sS time.apple.com\n"); - fprintf(stderr, " Windows: w32tm /resync%s\n", [RESET UTF8String]); - exit(1); - } -} +/** + * UNAuthenticationError - Invalid or missing credentials. + */ +@interface UNAuthenticationError : UNError +@end -NSString* hmacSha256Hex(NSString* key, NSString* message) { +@implementation UNAuthenticationError +@end + +/** + * UNExecutionError - Code execution failed. + */ +@interface UNExecutionError : UNError +@property (nonatomic) int exitCode; +@property (nonatomic, strong) NSString* stderr; +@end + +@implementation UNExecutionError +@end + +/** + * UNAPIError - API request failed. + */ +@interface UNAPIError : UNError +@property (nonatomic) NSInteger statusCode; +@property (nonatomic, strong) NSString* response; +@end + +@implementation UNAPIError +@end + +/** + * UNTimeoutError - Execution or polling timed out. + */ +@interface UNTimeoutError : UNError +@end + +@implementation UNTimeoutError +@end + +// ============================================================================ +// HMAC Authentication +// ============================================================================ + +/** + * Generate HMAC-SHA256 signature in hex format. + * + * @param key The secret key for HMAC + * @param message The message to sign + * @return Hex-encoded signature string + */ +NSString* UNHmacSha256Hex(NSString* key, NSString* message) { const char* cKey = [key UTF8String]; const char* cMessage = [message UTF8String]; unsigned char digest[CC_SHA256_DIGEST_LENGTH]; @@ -126,33 +187,738 @@ NSString* hmacSha256Hex(NSString* key, NSString* message) { return hex; } -NSString* computeSignature(NSString* secretKey, long timestamp, NSString* method, NSString* path, NSString* body) { - NSString* message = [NSString stringWithFormat:@"%ld:%@:%@:%@", timestamp, method, path, body]; - return hmacSha256Hex(secretKey, message); +/** + * Compute API request signature. + * Signature = HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") + * + * @param secretKey API secret key + * @param timestamp Unix timestamp + * @param method HTTP method (GET, POST, etc.) + * @param path API endpoint path + * @param body Request body (empty string if none) + * @return Hex-encoded signature + */ +NSString* UNComputeSignature(NSString* secretKey, long timestamp, NSString* method, NSString* path, NSString* body) { + NSString* message = [NSString stringWithFormat:@"%ld:%@:%@:%@", timestamp, method, path, body ?: @""]; + return UNHmacSha256Hex(secretKey, message); } -NSString* detectLanguage(NSString* filename) { - NSString* ext = [filename pathExtension]; - NSDictionary* langMap = getExtMap(); +// ============================================================================ +// Credentials Loading +// ============================================================================ - NSString* language = langMap[ext]; - if (!language) { - fprintf(stderr, "%sError: Cannot detect language for %s%s\n", - [RED UTF8String], [filename UTF8String], [RESET UTF8String]); - exit(1); +/** + * Get API credentials from environment or config file. + * Priority: 1. Arguments, 2. Environment vars, 3. ~/.unsandbox/accounts.csv + * + * @param publicKey Output public key + * @param secretKey Output secret key + * @param argPublicKey Optional public key from arguments + * @param argSecretKey Optional secret key from arguments + * @param error Error output + * @return YES if credentials found, NO otherwise + */ +BOOL UNGetCredentials(NSString** publicKey, NSString** secretKey, NSString* argPublicKey, NSString* argSecretKey, NSError** error) { + // Priority 1: Function arguments + if (argPublicKey && argSecretKey && [argPublicKey length] > 0 && [argSecretKey length] > 0) { + *publicKey = argPublicKey; + *secretKey = argSecretKey; + return YES; } - return language; + // Priority 2: Environment variables + *publicKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_PUBLIC_KEY"]; + *secretKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_SECRET_KEY"]; + + if (*publicKey && *secretKey && [*publicKey length] > 0 && [*secretKey length] > 0) { + return YES; + } + + // Fall back to legacy UNSANDBOX_API_KEY + NSString* oldKey = [[[NSProcessInfo processInfo] environment] objectForKey:@"UNSANDBOX_API_KEY"]; + if (oldKey && [oldKey length] > 0) { + *publicKey = oldKey; + *secretKey = oldKey; + return YES; + } + + // Priority 3: Config file ~/.unsandbox/accounts.csv + NSString* home = NSHomeDirectory(); + NSString* accountsPath = [home stringByAppendingPathComponent:@".unsandbox/accounts.csv"]; + NSFileManager* fm = [NSFileManager defaultManager]; + + if ([fm fileExistsAtPath:accountsPath]) { + NSString* content = [NSString stringWithContentsOfFile:accountsPath encoding:NSUTF8StringEncoding error:nil]; + if (content) { + NSArray* lines = [content componentsSeparatedByString:@"\n"]; + for (NSString* line in lines) { + NSString* trimmed = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([trimmed length] == 0 || [trimmed hasPrefix:@"#"]) continue; + + NSArray* parts = [trimmed componentsSeparatedByString:@","]; + if ([parts count] >= 2) { + NSString* pk = [parts[0] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + NSString* sk = [parts[1] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([pk hasPrefix:@"unsb-pk-"] && [sk hasPrefix:@"unsb-sk-"]) { + *publicKey = pk; + *secretKey = sk; + return YES; + } + } + } + } + } + + if (error) { + *error = [UNAuthenticationError errorWithMessage: + @"No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " + "or create ~/.unsandbox/accounts.csv, or pass credentials to initializer."]; + } + return NO; } -NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* data, NSString* publicKey, NSString* secretKey) { - NSString* urlString = [API_BASE stringByAppendingString:endpoint]; +/** + * Get API keys for CLI commands (exits on failure). + */ +void UNGetApiKeysCLI(NSString** publicKey, NSString** secretKey) { + NSError* error = nil; + if (!UNGetCredentials(publicKey, secretKey, nil, nil, &error)) { + fprintf(stderr, "%s%s%s\n", [RED UTF8String], [[error localizedDescription] UTF8String], [RESET UTF8String]); + exit(1); + } +} + +// ============================================================================ +// Clock Drift Detection +// ============================================================================ + +/** + * Check response for timestamp/clock drift errors. + */ +void UNCheckClockDrift(NSString* response) { + NSString* responseLower = [response lowercaseString]; + if ([responseLower rangeOfString:@"timestamp"].location != NSNotFound && + ([responseLower rangeOfString:@"401"].location != NSNotFound || + [responseLower rangeOfString:@"expired"].location != NSNotFound || + [responseLower rangeOfString:@"invalid"].location != NSNotFound)) { + fprintf(stderr, "%sError: Request timestamp expired (must be within 5 minutes of server time)%s\n", + [RED UTF8String], [RESET UTF8String]); + fprintf(stderr, "%sYour computer's clock may have drifted.%s\n", + [YELLOW UTF8String], [RESET UTF8String]); + fprintf(stderr, "Check your system time and sync with NTP if needed:\n"); + fprintf(stderr, " Linux: sudo ntpdate -s time.nist.gov\n"); + fprintf(stderr, " macOS: sudo sntp -sS time.apple.com\n"); + fprintf(stderr, " Windows: w32tm /resync\n"); + exit(1); + } +} + +// ============================================================================ +// Languages Cache +// ============================================================================ + +/** + * Get path to languages cache file. + */ +NSString* UNLanguagesCachePath(void) { + NSString* home = NSHomeDirectory(); + return [home stringByAppendingPathComponent:@".unsandbox/languages.json"]; +} + +/** + * Check if languages cache is valid (less than 1 hour old). + */ +BOOL UNIsCacheValid(void) { + NSFileManager* fm = [NSFileManager defaultManager]; + NSString* cachePath = UNLanguagesCachePath(); + + if (![fm fileExistsAtPath:cachePath]) { + return NO; + } + + NSError* error = nil; + NSDictionary* attrs = [fm attributesOfItemAtPath:cachePath error:&error]; + if (error) { + return NO; + } + + NSDate* modDate = attrs[NSFileModificationDate]; + NSTimeInterval age = -[modDate timeIntervalSinceNow]; + return age < UN_LANGUAGES_CACHE_TTL; +} + +/** + * Read languages from cache file. + */ +NSDictionary* UNReadLanguagesCache(void) { + NSString* cachePath = UNLanguagesCachePath(); + NSFileManager* fm = [NSFileManager defaultManager]; + + if (![fm fileExistsAtPath:cachePath]) { + return nil; + } + + NSData* data = [NSData dataWithContentsOfFile:cachePath]; + if (!data) { + return nil; + } + + NSError* error = nil; + NSDictionary* result = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error]; + return error ? nil : result; +} + +/** + * Write languages to cache file. + */ +void UNWriteLanguagesCache(NSDictionary* data) { + NSString* cachePath = UNLanguagesCachePath(); + NSString* cacheDir = [cachePath stringByDeletingLastPathComponent]; + NSFileManager* fm = [NSFileManager defaultManager]; + + // Create directory if needed + if (![fm fileExistsAtPath:cacheDir]) { + [fm createDirectoryAtPath:cacheDir withIntermediateDirectories:YES attributes:nil error:nil]; + } + + NSError* error = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; + if (!error && jsonData) { + [jsonData writeToFile:cachePath atomically:YES]; + } +} + +// ============================================================================ +// Language Detection +// ============================================================================ + +/** + * Detect programming language from file extension or shebang. + * + * @param filename Path to source file + * @return Language identifier or nil if undetected + */ +NSString* UNDetectLanguage(NSString* filename) { + NSString* ext = [filename pathExtension]; + NSDictionary* langMap = UNGetExtMap(); + + NSString* language = langMap[ext]; + if (language) { + return language; + } + + // Try reading shebang + NSFileManager* fm = [NSFileManager defaultManager]; + if ([fm fileExistsAtPath:filename]) { + NSString* content = [NSString stringWithContentsOfFile:filename encoding:NSUTF8StringEncoding error:nil]; + if (content) { + NSString* firstLine = [[content componentsSeparatedByString:@"\n"] firstObject]; + if ([firstLine hasPrefix:@"#!"]) { + if ([firstLine rangeOfString:@"python"].location != NSNotFound) return @"python"; + if ([firstLine rangeOfString:@"node"].location != NSNotFound) return @"javascript"; + if ([firstLine rangeOfString:@"ruby"].location != NSNotFound) return @"ruby"; + if ([firstLine rangeOfString:@"perl"].location != NSNotFound) return @"perl"; + if ([firstLine rangeOfString:@"bash"].location != NSNotFound || + [firstLine rangeOfString:@"/sh"].location != NSNotFound) return @"bash"; + if ([firstLine rangeOfString:@"lua"].location != NSNotFound) return @"lua"; + if ([firstLine rangeOfString:@"php"].location != NSNotFound) return @"php"; + } + } + } + + return nil; +} + +// ============================================================================ +// UNClient Class - Main SDK Interface +// ============================================================================ + +/** + * UNClient - Unsandbox API client with stored credentials. + * + * Example usage: + * UNClient *client = [[UNClient alloc] init]; + * NSDictionary *result = [client execute:@"python" code:@"print('Hello')"]; + * NSLog(@"Output: %@", result[@"stdout"]); + * + * // Or with explicit credentials: + * UNClient *client = [[UNClient alloc] initWithPublicKey:@"unsb-pk-..." secretKey:@"unsb-sk-..."]; + */ +@interface UNClient : NSObject + +@property (nonatomic, strong, readonly) NSString* publicKey; +@property (nonatomic, strong, readonly) NSString* secretKey; + +/** + * Initialize client with automatic credential loading. + * Loads from environment variables or ~/.unsandbox/accounts.csv + */ +- (instancetype)init; + +/** + * Initialize client with explicit credentials. + * + * @param publicKey API public key (unsb-pk-...) + * @param secretKey API secret key (unsb-sk-...) + */ +- (instancetype)initWithPublicKey:(NSString*)publicKey secretKey:(NSString*)secretKey; + +/** + * Execute code synchronously. + * + * @param language Programming language (python, javascript, go, rust, etc.) + * @param code Source code to execute + * @return Dictionary with stdout, stderr, exit_code, job_id + */ +- (NSDictionary*)execute:(NSString*)language code:(NSString*)code; + +/** + * Execute code with options. + * + * @param language Programming language + * @param code Source code + * @param options Dictionary with optional keys: env, input_files, network_mode, ttl, vcpu, return_artifact + * @return Dictionary with stdout, stderr, exit_code, job_id + */ +- (NSDictionary*)execute:(NSString*)language code:(NSString*)code options:(NSDictionary*)options; + +/** + * Execute code asynchronously. Returns immediately with job_id. + * + * @param language Programming language + * @param code Source code + * @param options Optional execution options + * @return Dictionary with job_id, status ("pending") + */ +- (NSDictionary*)executeAsync:(NSString*)language code:(NSString*)code options:(NSDictionary*)options; + +/** + * Execute code with automatic language detection from shebang. + * + * @param code Source code with shebang (e.g., #!/usr/bin/env python3) + * @return Dictionary with detected_language, stdout, stderr, etc. + */ +- (NSDictionary*)run:(NSString*)code; + +/** + * Execute with auto-detect, asynchronously. + * + * @param code Source code with shebang + * @return Dictionary with job_id, detected_language, status + */ +- (NSDictionary*)runAsync:(NSString*)code; + +/** + * Get job status and results. + * + * @param jobId Job ID from executeAsync or runAsync + * @return Dictionary with job_id, status, result (if completed) + */ +- (NSDictionary*)getJob:(NSString*)jobId; + +/** + * Wait for job completion with exponential backoff polling. + * + * @param jobId Job ID to wait for + * @return Final job result dictionary + */ +- (NSDictionary*)wait:(NSString*)jobId; + +/** + * Wait for job with max polls limit. + * + * @param jobId Job ID to wait for + * @param maxPolls Maximum number of poll attempts + * @return Final job result dictionary + */ +- (NSDictionary*)wait:(NSString*)jobId maxPolls:(int)maxPolls; + +/** + * Cancel a running job. + * + * @param jobId Job ID to cancel + * @return Dictionary with partial output collected before cancellation + */ +- (NSDictionary*)cancelJob:(NSString*)jobId; + +/** + * List all active jobs for this API key. + * + * @return Array of job summary dictionaries + */ +- (NSArray*)listJobs; + +/** + * Generate images from text prompt. + * + * @param prompt Text description of the image to generate + * @return Dictionary with images array, created_at + */ +- (NSDictionary*)image:(NSString*)prompt; + +/** + * Generate images with options. + * + * @param prompt Text prompt + * @param options Dictionary with optional keys: model, size, quality, n + * @return Dictionary with images array + */ +- (NSDictionary*)image:(NSString*)prompt options:(NSDictionary*)options; + +/** + * Get list of supported programming languages. + * Results are cached in ~/.unsandbox/languages.json for 1 hour. + * + * @return Dictionary with languages array, count, aliases + */ +- (NSDictionary*)languages; + +/** + * Make authenticated API request. + * + * @param endpoint API endpoint (e.g., /execute) + * @param method HTTP method + * @param data Request body dictionary (or nil) + * @return Response dictionary + */ +- (NSDictionary*)apiRequest:(NSString*)endpoint method:(NSString*)method data:(NSDictionary*)data; + +/** + * Make API request with text/plain body. + */ +- (NSDictionary*)apiRequestText:(NSString*)endpoint method:(NSString*)method body:(NSString*)body; + +@end + +@implementation UNClient + +- (instancetype)init { + self = [super init]; + if (self) { + NSString* pk = nil; + NSString* sk = nil; + NSError* error = nil; + if (!UNGetCredentials(&pk, &sk, nil, nil, &error)) { + @throw [NSException exceptionWithName:@"UNAuthenticationError" + reason:[error localizedDescription] + userInfo:nil]; + } + _publicKey = pk; + _secretKey = sk; + } + return self; +} + +- (instancetype)initWithPublicKey:(NSString*)publicKey secretKey:(NSString*)secretKey { + self = [super init]; + if (self) { + _publicKey = publicKey; + _secretKey = secretKey; + } + return self; +} + +- (NSDictionary*)apiRequest:(NSString*)endpoint method:(NSString*)method data:(NSDictionary*)data { + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; NSURL* url = [NSURL URLWithString:urlString]; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; [request setHTTPMethod:method]; - [request setTimeoutInterval:300]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; // Prepare body + NSString* bodyString = @""; + if (data) { + NSError* error = nil; + NSData* jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; + if (error) { + return @{@"error": [error localizedDescription]}; + } + bodyString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + [request setHTTPBody:jsonData]; + } + + // Generate timestamp and signature + long timestamp = (long)[[NSDate date] timeIntervalSince1970]; + NSString* signature = UNComputeSignature(_secretKey, timestamp, method, endpoint, bodyString); + + // Set headers + [request setValue:[@"Bearer " stringByAppendingString:_publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + + NSHTTPURLResponse* response = nil; + NSError* error = nil; + NSData* responseData = [NSURLConnection sendSynchronousRequest:request + returningResponse:&response + error:&error]; + + if (error) { + return @{@"error": [error localizedDescription]}; + } + + if ([response statusCode] != 200 && [response statusCode] != 201) { + NSString* errMsg = responseData ? [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] : @"Unknown error"; + return @{@"error": errMsg, @"status_code": @([response statusCode])}; + } + + NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; + if (error) { + return @{@"error": [error localizedDescription]}; + } + + return result; +} + +- (NSDictionary*)apiRequestText:(NSString*)endpoint method:(NSString*)method body:(NSString*)body { + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; + NSURL* url = [NSURL URLWithString:urlString]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + [request setHTTPMethod:method]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; + [request setHTTPBody:[body dataUsingEncoding:NSUTF8StringEncoding]]; + + long timestamp = (long)[[NSDate date] timeIntervalSince1970]; + NSString* signature = UNComputeSignature(_secretKey, timestamp, method, endpoint, body); + + [request setValue:[@"Bearer " stringByAppendingString:_publicKey] forHTTPHeaderField:@"Authorization"]; + [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; + [request setValue:signature forHTTPHeaderField:@"X-Signature"]; + [request setValue:@"text/plain" forHTTPHeaderField:@"Content-Type"]; + + NSHTTPURLResponse* response = nil; + NSError* error = nil; + NSData* responseData = [NSURLConnection sendSynchronousRequest:request + returningResponse:&response + error:&error]; + + if (error || ([response statusCode] != 200 && [response statusCode] != 201)) { + return @{@"error": @"Request failed"}; + } + + NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; + return result ?: @{}; +} + +- (NSDictionary*)execute:(NSString*)language code:(NSString*)code { + return [self execute:language code:code options:nil]; +} + +- (NSDictionary*)execute:(NSString*)language code:(NSString*)code options:(NSDictionary*)options { + NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{ + @"language": language, + @"code": code, + @"network_mode": options[@"network_mode"] ?: @"zerotrust", + @"ttl": options[@"ttl"] ?: @(UN_DEFAULT_TTL), + @"vcpu": options[@"vcpu"] ?: @1 + }]; + + if (options[@"env"]) payload[@"env"] = options[@"env"]; + if (options[@"input_files"]) payload[@"input_files"] = options[@"input_files"]; + if ([options[@"return_artifact"] boolValue]) payload[@"return_artifact"] = @YES; + + return [self apiRequest:@"/execute" method:@"POST" data:payload]; +} + +- (NSDictionary*)executeAsync:(NSString*)language code:(NSString*)code options:(NSDictionary*)options { + NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{ + @"language": language, + @"code": code, + @"network_mode": options[@"network_mode"] ?: @"zerotrust", + @"ttl": options[@"ttl"] ?: @(UN_DEFAULT_TTL), + @"vcpu": options[@"vcpu"] ?: @1 + }]; + + if (options[@"env"]) payload[@"env"] = options[@"env"]; + if (options[@"input_files"]) payload[@"input_files"] = options[@"input_files"]; + if ([options[@"return_artifact"] boolValue]) payload[@"return_artifact"] = @YES; + + return [self apiRequest:@"/execute/async" method:@"POST" data:payload]; +} + +- (NSDictionary*)run:(NSString*)code { + NSString* endpoint = [NSString stringWithFormat:@"/run?ttl=%ld&network_mode=zerotrust", (long)UN_DEFAULT_TTL]; + return [self apiRequestText:endpoint method:@"POST" body:code]; +} + +- (NSDictionary*)runAsync:(NSString*)code { + NSString* endpoint = [NSString stringWithFormat:@"/run/async?ttl=%ld&network_mode=zerotrust", (long)UN_DEFAULT_TTL]; + return [self apiRequestText:endpoint method:@"POST" body:code]; +} + +- (NSDictionary*)getJob:(NSString*)jobId { + NSString* endpoint = [NSString stringWithFormat:@"/jobs/%@", jobId]; + return [self apiRequest:endpoint method:@"GET" data:nil]; +} + +- (NSDictionary*)wait:(NSString*)jobId { + return [self wait:jobId maxPolls:100]; +} + +- (NSDictionary*)wait:(NSString*)jobId maxPolls:(int)maxPolls { + NSSet* terminalStates = [NSSet setWithArray:@[@"completed", @"failed", @"timeout", @"cancelled"]]; + + for (int i = 0; i < maxPolls; i++) { + int delayIdx = MIN(i, UN_POLL_DELAYS_COUNT - 1); + usleep(UN_POLL_DELAYS[delayIdx] * 1000); // Convert ms to microseconds + + NSDictionary* result = [self getJob:jobId]; + NSString* status = result[@"status"]; + + if ([terminalStates containsObject:status]) { + return result; + } + } + + return @{@"error": @"Max polls exceeded", @"job_id": jobId}; +} + +- (NSDictionary*)cancelJob:(NSString*)jobId { + NSString* endpoint = [NSString stringWithFormat:@"/jobs/%@", jobId]; + return [self apiRequest:endpoint method:@"DELETE" data:nil]; +} + +- (NSArray*)listJobs { + NSDictionary* result = [self apiRequest:@"/jobs" method:@"GET" data:nil]; + return result[@"jobs"] ?: @[]; +} + +- (NSDictionary*)image:(NSString*)prompt { + return [self image:prompt options:nil]; +} + +- (NSDictionary*)image:(NSString*)prompt options:(NSDictionary*)options { + NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{ + @"prompt": prompt, + @"size": options[@"size"] ?: @"1024x1024", + @"quality": options[@"quality"] ?: @"standard", + @"n": options[@"n"] ?: @1 + }]; + + if (options[@"model"]) payload[@"model"] = options[@"model"]; + + return [self apiRequest:@"/image" method:@"POST" data:payload]; +} + +- (NSDictionary*)languages { + // Check cache first + if (UNIsCacheValid()) { + NSDictionary* cached = UNReadLanguagesCache(); + if (cached) { + return cached; + } + } + + // Fetch from API + NSDictionary* result = [self apiRequest:@"/languages" method:@"GET" data:nil]; + + // Cache result (only if successful) + if (result && !result[@"error"]) { + UNWriteLanguagesCache(result); + } + + return result; +} + +@end + +// ============================================================================ +// Standalone Library Functions +// ============================================================================ + +/** + * Execute code synchronously (standalone function). + * Uses credentials from environment or config file. + */ +NSDictionary* UNExecute(NSString* language, NSString* code, NSDictionary* options) { + UNClient* client = [[UNClient alloc] init]; + return [client execute:language code:code options:options]; +} + +/** + * Execute code asynchronously (standalone function). + */ +NSDictionary* UNExecuteAsync(NSString* language, NSString* code, NSDictionary* options) { + UNClient* client = [[UNClient alloc] init]; + return [client executeAsync:language code:code options:options]; +} + +/** + * Execute with auto-detect (standalone function). + */ +NSDictionary* UNRun(NSString* code) { + UNClient* client = [[UNClient alloc] init]; + return [client run:code]; +} + +/** + * Execute async with auto-detect (standalone function). + */ +NSDictionary* UNRunAsync(NSString* code) { + UNClient* client = [[UNClient alloc] init]; + return [client runAsync:code]; +} + +/** + * Get job status (standalone function). + */ +NSDictionary* UNGetJob(NSString* jobId) { + UNClient* client = [[UNClient alloc] init]; + return [client getJob:jobId]; +} + +/** + * Wait for job completion (standalone function). + */ +NSDictionary* UNWait(NSString* jobId) { + UNClient* client = [[UNClient alloc] init]; + return [client wait:jobId]; +} + +/** + * Cancel a job (standalone function). + */ +NSDictionary* UNCancelJob(NSString* jobId) { + UNClient* client = [[UNClient alloc] init]; + return [client cancelJob:jobId]; +} + +/** + * List active jobs (standalone function). + */ +NSArray* UNListJobs(void) { + UNClient* client = [[UNClient alloc] init]; + return [client listJobs]; +} + +/** + * Generate image (standalone function). + */ +NSDictionary* UNImage(NSString* prompt, NSDictionary* options) { + UNClient* client = [[UNClient alloc] init]; + return [client image:prompt options:options]; +} + +/** + * Get supported languages (standalone function). + * Results are cached for 1 hour in ~/.unsandbox/languages.json + */ +NSDictionary* UNLanguages(void) { + UNClient* client = [[UNClient alloc] init]; + return [client languages]; +} + +// ============================================================================ +// CLI Helper Functions +// ============================================================================ + +NSDictionary* apiRequestCLI(NSString* endpoint, NSString* method, NSDictionary* data, NSString* publicKey, NSString* secretKey) { + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; + NSURL* url = [NSURL URLWithString:urlString]; + NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; + [request setHTTPMethod:method]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; + NSString* bodyString = @""; if (data) { NSError* error = nil; @@ -166,11 +932,9 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat [request setHTTPBody:jsonData]; } - // Generate timestamp and signature long timestamp = (long)[[NSDate date] timeIntervalSince1970]; - NSString* signature = computeSignature(secretKey, timestamp, method, endpoint, bodyString); + NSString* signature = UNComputeSignature(secretKey, timestamp, method, endpoint, bodyString); - // Set headers [request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"]; [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; [request setValue:signature forHTTPHeaderField:@"X-Signature"]; @@ -188,7 +952,7 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat if (responseData) { NSString* errMsg = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; fprintf(stderr, "%s\n", [errMsg UTF8String]); - checkClockDrift(errMsg); + UNCheckClockDrift(errMsg); } exit(1); } @@ -203,20 +967,17 @@ NSDictionary* apiRequest(NSString* endpoint, NSString* method, NSDictionary* dat return result; } -// API request with text/plain body (for vault) -NSDictionary* apiRequestPutText(NSString* endpoint, NSString* content, NSString* publicKey, NSString* secretKey) { - NSString* urlString = [API_BASE stringByAppendingString:endpoint]; +NSDictionary* apiRequestPutTextCLI(NSString* endpoint, NSString* content, NSString* publicKey, NSString* secretKey) { + NSString* urlString = [UN_API_BASE stringByAppendingString:endpoint]; NSURL* url = [NSURL URLWithString:urlString]; NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; [request setHTTPMethod:@"PUT"]; - [request setTimeoutInterval:300]; + [request setTimeoutInterval:UN_DEFAULT_TIMEOUT]; [request setHTTPBody:[content dataUsingEncoding:NSUTF8StringEncoding]]; - // Generate timestamp and signature long timestamp = (long)[[NSDate date] timeIntervalSince1970]; - NSString* signature = computeSignature(secretKey, timestamp, @"PUT", endpoint, content); + NSString* signature = UNComputeSignature(secretKey, timestamp, @"PUT", endpoint, content); - // Set headers [request setValue:[@"Bearer " stringByAppendingString:publicKey] forHTTPHeaderField:@"Authorization"]; [request setValue:[NSString stringWithFormat:@"%ld", timestamp] forHTTPHeaderField:@"X-Timestamp"]; [request setValue:signature forHTTPHeaderField:@"X-Signature"]; @@ -242,7 +1003,6 @@ NSDictionary* apiRequestPutText(NSString* endpoint, NSString* content, NSString* return result; } -// Build environment content from -e args and --env-file NSString* buildEnvContent(NSArray* envVars, NSString* envFile) { NSMutableArray* lines = [NSMutableArray array]; for (NSString* var in envVars) { @@ -262,7 +1022,7 @@ NSString* buildEnvContent(NSArray* envVars, NSString* envFile) { // Service vault functions void serviceEnvStatus(NSString* serviceId, NSString* publicKey, NSString* secretKey) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(endpoint, @"GET", nil, publicKey, secretKey); NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; printf("%s\n", [jsonString UTF8String]); @@ -270,7 +1030,7 @@ void serviceEnvStatus(NSString* serviceId, NSString* publicKey, NSString* secret void serviceEnvSet(NSString* serviceId, NSString* content, NSString* publicKey, NSString* secretKey) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId]; - NSDictionary* result = apiRequestPutText(endpoint, content, publicKey, secretKey); + NSDictionary* result = apiRequestPutTextCLI(endpoint, content, publicKey, secretKey); NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; printf("%s\n", [jsonString UTF8String]); @@ -278,7 +1038,7 @@ void serviceEnvSet(NSString* serviceId, NSString* content, NSString* publicKey, void serviceEnvExport(NSString* serviceId, NSString* publicKey, NSString* secretKey) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env/export", serviceId]; - NSDictionary* result = apiRequest(endpoint, @"POST", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(endpoint, @"POST", nil, publicKey, secretKey); if (result[@"content"]) { printf("%s", [result[@"content"] UTF8String]); } @@ -286,13 +1046,17 @@ void serviceEnvExport(NSString* serviceId, NSString* publicKey, NSString* secret void serviceEnvDelete(NSString* serviceId, NSString* publicKey, NSString* secretKey) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/env", serviceId]; - apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); + apiRequestCLI(endpoint, @"DELETE", nil, publicKey, secretKey); printf("%sVault deleted for: %s%s\n", [GREEN UTF8String], [serviceId UTF8String], [RESET UTF8String]); } +// ============================================================================ +// CLI Commands +// ============================================================================ + void cmdExecute(NSArray* args) { NSString* publicKey, *secretKey; - getApiKeys(&publicKey, &secretKey); + UNGetApiKeysCLI(&publicKey, &secretKey); NSString* sourceFile = nil; NSMutableDictionary* envVars = [NSMutableDictionary dictionary]; NSMutableArray* inputFiles = [NSMutableArray array]; @@ -301,13 +1065,12 @@ void cmdExecute(NSArray* args) { NSString* network = nil; int vcpu = 0; - // Parse arguments for (NSUInteger i = 0; i < [args count]; i++) { NSString* arg = args[i]; if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) { NSArray* parts = [args[++i] componentsSeparatedByString:@"="]; if ([parts count] >= 2) { - envVars[parts[0]] = [parts subarrayWithRange:NSMakeRange(1, [parts count] - 1)].componentsJoinedByString:@"="; + envVars[parts[0]] = [[parts subarrayWithRange:NSMakeRange(1, [parts count] - 1)] componentsJoinedByString:@"="]; } } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { [inputFiles addObject:args[++i]]; @@ -319,10 +1082,7 @@ void cmdExecute(NSArray* args) { network = args[++i]; } else if ([arg isEqualToString:@"-v"] && i + 1 < [args count]) { vcpu = [args[++i] intValue]; - } else if ([arg hasPrefix:@"-"]) { - fprintf(stderr, "%sUnknown option: %s%s\n", RED, [arg UTF8String], RESET); - exit(1); - } else { + } else if (![arg hasPrefix:@"-"]) { sourceFile = arg; } } @@ -339,7 +1099,6 @@ void cmdExecute(NSArray* args) { exit(1); } - // Read source file NSError* error = nil; NSString* code = [NSString stringWithContentsOfFile:sourceFile encoding:NSUTF8StringEncoding error:&error]; if (error) { @@ -348,9 +1107,13 @@ void cmdExecute(NSArray* args) { exit(1); } - NSString* language = detectLanguage(sourceFile); + NSString* language = UNDetectLanguage(sourceFile); + if (!language) { + fprintf(stderr, "%sError: Cannot detect language for %s%s\n", + [RED UTF8String], [sourceFile UTF8String], [RESET UTF8String]); + exit(1); + } - // Build request payload NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{ @"language": language, @"code": code @@ -378,20 +1141,12 @@ void cmdExecute(NSArray* args) { payload[@"input_files"] = files; } - if (artifacts) { - payload[@"return_artifacts"] = @YES; - } - if (network) { - payload[@"network"] = network; - } - if (vcpu > 0) { - payload[@"vcpu"] = @(vcpu); - } + if (artifacts) payload[@"return_artifacts"] = @YES; + if (network) payload[@"network"] = network; + if (vcpu > 0) payload[@"vcpu"] = @(vcpu); - // Execute - NSDictionary* result = apiRequest(@"/execute", @"POST", payload, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(@"/execute", @"POST", payload, publicKey, secretKey); - // Print output NSString* stdoutText = result[@"stdout"] ?: @""; NSString* stderrText = result[@"stderr"] ?: @""; @@ -402,7 +1157,6 @@ void cmdExecute(NSArray* args) { fprintf(stderr, "%s%s%s", [RED UTF8String], [stderrText UTF8String], [RESET UTF8String]); } - // Save artifacts if (artifacts && result[@"artifacts"]) { [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES attributes:nil error:nil]; for (NSDictionary* artifact in result[@"artifacts"]) { @@ -420,150 +1174,22 @@ void cmdExecute(NSArray* args) { exit(exitCode); } -NSDictionary* portalRequest(NSString* endpoint, NSString* method, NSDictionary* data, NSString* apiKey) { - NSString* urlString = [PORTAL_BASE stringByAppendingString:endpoint]; - NSURL* url = [NSURL URLWithString:urlString]; - NSMutableURLRequest* request = [NSMutableURLRequest requestWithURL:url]; - [request setHTTPMethod:method]; - [request setValue:[@"Bearer " stringByAppendingString:apiKey] forHTTPHeaderField:@"Authorization"]; - [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; - [request setTimeoutInterval:30]; - - if (data) { - NSError* error = nil; - NSData* jsonData = [NSJSONSerialization dataWithJSONObject:data options:0 error:&error]; - if (error) { - fprintf(stderr, "%sError creating JSON: %s%s\n", - [RED UTF8String], [[error localizedDescription] UTF8String], [RESET UTF8String]); - exit(1); - } - [request setHTTPBody:jsonData]; - } - - NSHTTPURLResponse* response = nil; - NSError* error = nil; - NSData* responseData = [NSURLConnection sendSynchronousRequest:request - returningResponse:&response - error:&error]; - - if (error || [response statusCode] >= 400) { - // For key validation, return the parsed JSON even on error - if (responseData) { - NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; - if (result) { - return result; - } - } - fprintf(stderr, "%sError: HTTP %ld%s\n", - [RED UTF8String], (long)[response statusCode], [RESET UTF8String]); - if (responseData) { - NSString* errMsg = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; - fprintf(stderr, "%s\n", [errMsg UTF8String]); - checkClockDrift(errMsg); - } - exit(1); - } - - NSDictionary* result = [NSJSONSerialization JSONObjectWithData:responseData options:0 error:&error]; - if (error) { - fprintf(stderr, "%sError parsing JSON: %s%s\n", - [RED UTF8String], [[error localizedDescription] UTF8String], [RESET UTF8String]); - exit(1); - } - - return result; -} - -void openBrowser(NSString* url) { - NSString* command = [NSString stringWithFormat:@"open \"%@\"", url]; - system([command UTF8String]); -} - -void validateKey(NSString* apiKey, BOOL shouldExtend) { - NSDictionary* result = portalRequest(@"/keys/validate", @"POST", @{}, apiKey); - - if ([result[@"expired"] boolValue]) { - printf("%sExpired%s\n", [RED UTF8String], [RESET UTF8String]); - printf("Public Key: %s\n", [result[@"public_key"] UTF8String] ?: "N/A"); - printf("Tier: %s\n", [result[@"tier"] UTF8String] ?: "N/A"); - printf("Expired: %s\n", [result[@"expires_at"] UTF8String] ?: "N/A"); - printf("%sTo renew: Visit https://unsandbox.com/keys/extend%s\n", - [YELLOW UTF8String], [RESET UTF8String]); - - if (shouldExtend && result[@"public_key"]) { - NSString* extendUrl = [NSString stringWithFormat:@"%@/keys/extend?pk=%@", - PORTAL_BASE, result[@"public_key"]]; - printf("\n%sOpening browser to extend key...%s\n", [BLUE UTF8String], [RESET UTF8String]); - openBrowser(extendUrl); - } - exit(1); - } - - printf("%sValid%s\n", [GREEN UTF8String], [RESET UTF8String]); - printf("Public Key: %s\n", [result[@"public_key"] UTF8String] ?: "N/A"); - printf("Tier: %s\n", [result[@"tier"] UTF8String] ?: "N/A"); - printf("Status: %s\n", [result[@"status"] UTF8String] ?: "N/A"); - printf("Expires: %s\n", [result[@"expires_at"] UTF8String] ?: "N/A"); - printf("Time Remaining: %s\n", [result[@"time_remaining"] UTF8String] ?: "N/A"); - printf("Rate Limit: %s\n", [result[@"rate_limit"] UTF8String] ?: "N/A"); - printf("Burst: %s\n", [result[@"burst"] UTF8String] ?: "N/A"); - printf("Concurrency: %s\n", [result[@"concurrency"] UTF8String] ?: "N/A"); - - if (shouldExtend && result[@"public_key"]) { - NSString* extendUrl = [NSString stringWithFormat:@"%@/keys/extend?pk=%@", - PORTAL_BASE, result[@"public_key"]]; - printf("\n%sOpening browser to extend key...%s\n", [BLUE UTF8String], [RESET UTF8String]); - openBrowser(extendUrl); - } -} - -void cmdKey(NSArray* args) { - NSString* publicKey, *secretKey; - getApiKeys(&publicKey, &secretKey); - BOOL shouldExtend = NO; - - for (NSString* arg in args) { - if ([arg isEqualToString:@"--extend"]) { - shouldExtend = YES; - } - } - - // For portal validation, we use public key as bearer token - validateKey(publicKey, shouldExtend); -} - void cmdSession(NSArray* args) { NSString* publicKey, *secretKey; - getApiKeys(&publicKey, &secretKey); + UNGetApiKeysCLI(&publicKey, &secretKey); BOOL listMode = NO; NSString* killId = nil; NSString* shell = nil; NSString* network = nil; int vcpu = 0; NSMutableArray* inputFiles = [NSMutableArray array]; - NSString* snapshotId = nil; - NSString* restoreId = nil; - NSString* fromSnapshot = nil; - NSString* snapshotName = nil; - BOOL hotSnapshot = NO; - // Parse arguments for (NSUInteger i = 0; i < [args count]; i++) { NSString* arg = args[i]; if ([arg isEqualToString:@"--list"]) { listMode = YES; } else if ([arg isEqualToString:@"--kill"] && i + 1 < [args count]) { killId = args[++i]; - } else if ([arg isEqualToString:@"--snapshot"] && i + 1 < [args count]) { - snapshotId = args[++i]; - } else if ([arg isEqualToString:@"--restore"] && i + 1 < [args count]) { - restoreId = args[++i]; - } else if ([arg isEqualToString:@"--from"] && i + 1 < [args count]) { - fromSnapshot = args[++i]; - } else if ([arg isEqualToString:@"--snapshot-name"] && i + 1 < [args count]) { - snapshotName = args[++i]; - } else if ([arg isEqualToString:@"--hot"]) { - hotSnapshot = YES; } else if ([arg isEqualToString:@"--shell"] && i + 1 < [args count]) { shell = args[++i]; } else if ([arg isEqualToString:@"-f"] && i + 1 < [args count]) { @@ -576,7 +1202,7 @@ void cmdSession(NSArray* args) { } if (listMode) { - NSDictionary* result = apiRequest(@"/sessions", @"GET", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(@"/sessions", @"GET", nil, publicKey, secretKey); NSArray* sessions = result[@"sessions"]; if ([sessions count] == 0) { printf("No active sessions\n"); @@ -595,39 +1221,17 @@ void cmdSession(NSArray* args) { if (killId) { NSString* endpoint = [NSString stringWithFormat:@"/sessions/%@", killId]; - apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); + apiRequestCLI(endpoint, @"DELETE", nil, publicKey, secretKey); printf("%sSession terminated: %s%s\n", [GREEN UTF8String], [killId UTF8String], [RESET UTF8String]); return; } - if (snapshotId) { - fprintf(stderr, "Creating snapshot of session %s...\n", [snapshotId UTF8String]); - NSMutableDictionary* payload = [NSMutableDictionary dictionary]; - if (snapshotName) payload[@"name"] = snapshotName; - if (hotSnapshot) payload[@"hot"] = @YES; - NSString* endpoint = [NSString stringWithFormat:@"/sessions/%@/snapshot", snapshotId]; - NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey); - printf("%sSnapshot created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); - return; - } - - if (restoreId) { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - fprintf(stderr, "Restoring from snapshot %s...\n", [restoreId UTF8String]); - NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/restore", restoreId]; - apiRequest(endpoint, @"POST", @{}, publicKey, secretKey); - printf("%sSession restored from snapshot%s\n", [GREEN UTF8String], [RESET UTF8String]); - return; - } - - // Create new session NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{ @"shell": shell ?: @"bash" }]; if (network) payload[@"network"] = network; if (vcpu > 0) payload[@"vcpu"] = @(vcpu); - // Add input files if ([inputFiles count] > 0) { NSFileManager* fm = [NSFileManager defaultManager]; NSMutableArray* files = [NSMutableArray array]; @@ -648,7 +1252,7 @@ void cmdSession(NSArray* args) { } printf("%sCreating session...%s\n", [YELLOW UTF8String], [RESET UTF8String]); - NSDictionary* result = apiRequest(@"/sessions", @"POST", payload, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(@"/sessions", @"POST", payload, publicKey, secretKey); printf("%sSession created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); printf("%s(Interactive sessions require WebSocket - use un2 for full support)%s\n", [YELLOW UTF8String], [RESET UTF8String]); @@ -656,16 +1260,13 @@ void cmdSession(NSArray* args) { void cmdService(NSArray* args) { NSString* publicKey, *secretKey; - getApiKeys(&publicKey, &secretKey); + UNGetApiKeysCLI(&publicKey, &secretKey); BOOL listMode = NO; NSString* infoId = nil; NSString* logsId = nil; NSString* sleepId = nil; NSString* wakeId = nil; NSString* destroyId = nil; - NSString* resizeId = nil; - NSString* dumpBootstrapId = nil; - NSString* dumpFile = nil; NSString* name = nil; NSString* ports = nil; NSString* type = nil; @@ -676,11 +1277,6 @@ void cmdService(NSArray* args) { NSMutableArray* inputFiles = [NSMutableArray array]; NSMutableArray* envVars = [NSMutableArray array]; NSString* envFile = nil; - NSString* snapshotId = nil; - NSString* restoreId = nil; - NSString* fromSnapshot = nil; - NSString* snapshotName = nil; - BOOL hotSnapshot = NO; // Check for 'env' subcommand first if ([args count] >= 1 && [args[0] isEqualToString:@"env"]) { @@ -691,7 +1287,6 @@ void cmdService(NSArray* args) { NSString* envAction = args[1]; NSString* envTarget = args[2]; - // Parse remaining args for -e and --env-file for (NSUInteger i = 3; i < [args count]; i++) { NSString* arg = args[i]; if ([arg isEqualToString:@"-e"] && i + 1 < [args count]) { @@ -722,7 +1317,6 @@ void cmdService(NSArray* args) { return; } - // Parse arguments for (NSUInteger i = 0; i < [args count]; i++) { NSString* arg = args[i]; if ([arg isEqualToString:@"--list"]) { @@ -737,24 +1331,6 @@ void cmdService(NSArray* args) { wakeId = args[++i]; } else if ([arg isEqualToString:@"--destroy"] && i + 1 < [args count]) { destroyId = args[++i]; - } else if ([arg isEqualToString:@"--resize"] && i + 1 < [args count]) { - resizeId = args[++i]; - } else if ([arg isEqualToString:@"--vcpu"] && i + 1 < [args count]) { - vcpu = [args[++i] intValue]; - } else if ([arg isEqualToString:@"--dump-bootstrap"] && i + 1 < [args count]) { - dumpBootstrapId = args[++i]; - } else if ([arg isEqualToString:@"--dump-file"] && i + 1 < [args count]) { - dumpFile = args[++i]; - } else if ([arg isEqualToString:@"--snapshot"] && i + 1 < [args count]) { - snapshotId = args[++i]; - } else if ([arg isEqualToString:@"--restore"] && i + 1 < [args count]) { - restoreId = args[++i]; - } else if ([arg isEqualToString:@"--from"] && i + 1 < [args count]) { - fromSnapshot = args[++i]; - } else if ([arg isEqualToString:@"--snapshot-name"] && i + 1 < [args count]) { - snapshotName = args[++i]; - } else if ([arg isEqualToString:@"--hot"]) { - hotSnapshot = YES; } else if ([arg isEqualToString:@"--name"] && i + 1 < [args count]) { name = args[++i]; } else if ([arg isEqualToString:@"--ports"] && i + 1 < [args count]) { @@ -779,7 +1355,7 @@ void cmdService(NSArray* args) { } if (listMode) { - NSDictionary* result = apiRequest(@"/services", @"GET", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(@"/services", @"GET", nil, publicKey, secretKey); NSArray* services = result[@"services"]; if ([services count] == 0) { printf("No services\n"); @@ -803,7 +1379,7 @@ void cmdService(NSArray* args) { if (infoId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@", infoId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(endpoint, @"GET", nil, publicKey, secretKey); NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; printf("%s\n", [jsonString UTF8String]); @@ -812,103 +1388,32 @@ void cmdService(NSArray* args) { if (logsId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/logs", logsId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(endpoint, @"GET", nil, publicKey, secretKey); printf("%s", [result[@"logs"] UTF8String]); return; } if (sleepId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/freeze", sleepId]; - apiRequest(endpoint, @"POST", nil, publicKey, secretKey); + apiRequestCLI(endpoint, @"POST", nil, publicKey, secretKey); printf("%sService frozen: %s%s\n", [GREEN UTF8String], [sleepId UTF8String], [RESET UTF8String]); return; } if (wakeId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@/unfreeze", wakeId]; - apiRequest(endpoint, @"POST", nil, publicKey, secretKey); + apiRequestCLI(endpoint, @"POST", nil, publicKey, secretKey); printf("%sService unfreezing: %s%s\n", [GREEN UTF8String], [wakeId UTF8String], [RESET UTF8String]); return; } if (destroyId) { NSString* endpoint = [NSString stringWithFormat:@"/services/%@", destroyId]; - apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); + apiRequestCLI(endpoint, @"DELETE", nil, publicKey, secretKey); printf("%sService destroyed: %s%s\n", [GREEN UTF8String], [destroyId UTF8String], [RESET UTF8String]); return; } - if (resizeId) { - if (vcpu <= 0) { - fprintf(stderr, "%sError: --resize requires --vcpu or -v%s\n", [RED UTF8String], [RESET UTF8String]); - exit(1); - } - if (vcpu < 1 || vcpu > 8) { - fprintf(stderr, "%sError: vCPU must be between 1 and 8%s\n", [RED UTF8String], [RESET UTF8String]); - exit(1); - } - NSString* endpoint = [NSString stringWithFormat:@"/services/%@", resizeId]; - NSDictionary* payload = @{@"vcpu": @(vcpu)}; - apiRequest(endpoint, @"PATCH", payload, publicKey, secretKey); - int ram = vcpu * 2; - printf("%sService resized to %d vCPU, %d GB RAM%s\n", [GREEN UTF8String], vcpu, ram, [RESET UTF8String]); - return; - } - - if (dumpBootstrapId) { - fprintf(stderr, "Fetching bootstrap script from %s...\n", [dumpBootstrapId UTF8String]); - NSDictionary* payload = @{@"command": @"cat /tmp/bootstrap.sh"}; - NSString* endpoint = [NSString stringWithFormat:@"/services/%@/execute", dumpBootstrapId]; - NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey); - - if (result[@"stdout"] && [result[@"stdout"] length] > 0) { - NSString* bootstrap = result[@"stdout"]; - if (dumpFile) { - // Write to file - NSError* error = nil; - [bootstrap writeToFile:dumpFile atomically:YES encoding:NSUTF8StringEncoding error:&error]; - if (error) { - fprintf(stderr, "%sError: Could not write to %s: %s%s\n", - [RED UTF8String], [dumpFile UTF8String], - [[error localizedDescription] UTF8String], [RESET UTF8String]); - exit(1); - } - NSFileManager* fm = [NSFileManager defaultManager]; - [fm setAttributes:@{NSFilePosixPermissions: @0755} ofItemAtPath:dumpFile error:nil]; - printf("Bootstrap saved to %s\n", [dumpFile UTF8String]); - } else { - // Print to stdout - printf("%s", [bootstrap UTF8String]); - } - } else { - fprintf(stderr, "%sError: Failed to fetch bootstrap (service not running or no bootstrap file)%s\n", - [RED UTF8String], [RESET UTF8String]); - exit(1); - } - return; - } - - if (snapshotId) { - fprintf(stderr, "Creating snapshot of service %s...\n", [snapshotId UTF8String]); - NSMutableDictionary* payload = [NSMutableDictionary dictionary]; - if (snapshotName) payload[@"name"] = snapshotName; - if (hotSnapshot) payload[@"hot"] = @YES; - NSString* endpoint = [NSString stringWithFormat:@"/services/%@/snapshot", snapshotId]; - NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey); - printf("%sSnapshot created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); - return; - } - - if (restoreId) { - // --restore takes snapshot ID directly, calls /snapshots/:id/restore - fprintf(stderr, "Restoring from snapshot %s...\n", [restoreId UTF8String]); - NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/restore", restoreId]; - apiRequest(endpoint, @"POST", @{}, publicKey, secretKey); - printf("%sService restored from snapshot%s\n", [GREEN UTF8String], [RESET UTF8String]); - return; - } - - // Create new service if (name) { NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{@"name": name}]; @@ -921,13 +1426,8 @@ void cmdService(NSArray* args) { payload[@"ports"] = portNumbers; } - if (type) { - payload[@"service_type"] = type; - } - - if (bootstrap) { - payload[@"bootstrap"] = bootstrap; - } + if (type) payload[@"service_type"] = type; + if (bootstrap) payload[@"bootstrap"] = bootstrap; if (bootstrapFile) { NSFileManager* fm = [NSFileManager defaultManager]; @@ -941,7 +1441,6 @@ void cmdService(NSArray* args) { } } - // Add input files if ([inputFiles count] > 0) { NSFileManager* fm = [NSFileManager defaultManager]; NSMutableArray* files = [NSMutableArray array]; @@ -964,14 +1463,13 @@ void cmdService(NSArray* args) { if (network) payload[@"network"] = network; if (vcpu > 0) payload[@"vcpu"] = @(vcpu); - NSDictionary* result = apiRequest(@"/services", @"POST", payload, publicKey, secretKey); + NSDictionary* result = apiRequestCLI(@"/services", @"POST", payload, publicKey, secretKey); printf("%sService created: %s%s\n", [GREEN UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); printf("Name: %s\n", [result[@"name"] UTF8String]); if (result[@"url"]) { printf("URL: %s\n", [result[@"url"] UTF8String]); } - // Auto-set vault if -e or --env-file were provided NSString* envContent = buildEnvContent(envVars, envFile); if ([envContent length] > 0 && result[@"id"]) { printf("%sSetting vault for service...%s\n", [YELLOW UTF8String], [RESET UTF8String]); @@ -985,94 +1483,58 @@ void cmdService(NSArray* args) { exit(1); } -void cmdSnapshot(NSArray* args) { +void cmdKey(NSArray* args) { NSString* publicKey, *secretKey; - getApiKeys(&publicKey, &secretKey); - BOOL listMode = NO; - NSString* infoId = nil; - NSString* deleteId = nil; - NSString* cloneId = nil; - NSString* cloneType = nil; - NSString* cloneName = nil; + UNGetApiKeysCLI(&publicKey, &secretKey); - for (NSUInteger i = 0; i < [args count]; i++) { - NSString* arg = args[i]; - if ([arg isEqualToString:@"--list"] || [arg isEqualToString:@"-l"]) { - listMode = YES; - } else if ([arg isEqualToString:@"--info"] && i + 1 < [args count]) { - infoId = args[++i]; - } else if ([arg isEqualToString:@"--delete"] && i + 1 < [args count]) { - deleteId = args[++i]; - } else if ([arg isEqualToString:@"--clone"] && i + 1 < [args count]) { - cloneId = args[++i]; - } else if ([arg isEqualToString:@"--type"] && i + 1 < [args count]) { - cloneType = args[++i]; - } else if ([arg isEqualToString:@"--name"] && i + 1 < [args count]) { - cloneName = args[++i]; - } - } - - if (listMode) { - NSDictionary* result = apiRequest(@"/snapshots", @"GET", nil, publicKey, secretKey); - NSArray* snapshots = result[@"snapshots"]; - if ([snapshots count] == 0) { - printf("No snapshots found\n"); - } else { - printf("%-40s %-20s %-12s %-30s\n", "SNAPSHOT ID", "NAME", "SOURCE TYPE", "SOURCE ID"); - for (NSDictionary* s in snapshots) { - printf("%-40s %-20s %-12s %-30s\n", - [s[@"id"] UTF8String], - [s[@"name"] UTF8String] ?: "-", - [s[@"source_type"] UTF8String], - [s[@"source_id"] UTF8String]); - } - } - return; - } - - if (infoId) { - NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", infoId]; - NSDictionary* result = apiRequest(endpoint, @"GET", nil, publicKey, secretKey); - NSData* jsonData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil]; - NSString* jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; - printf("%s\n", [jsonString UTF8String]); - return; - } - - if (deleteId) { - NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@", deleteId]; - apiRequest(endpoint, @"DELETE", nil, publicKey, secretKey); - printf("%sSnapshot deleted successfully%s\n", [GREEN UTF8String], [RESET UTF8String]); - return; - } - - if (cloneId) { - if (!cloneType) { - fprintf(stderr, "%sError: --type required with --clone (session or service)%s\n", [RED UTF8String], [RESET UTF8String]); - exit(1); - } - NSMutableDictionary* payload = [NSMutableDictionary dictionaryWithDictionary:@{@"type": cloneType}]; - if (cloneName) payload[@"name"] = cloneName; - NSString* endpoint = [NSString stringWithFormat:@"/snapshots/%@/clone", cloneId]; - NSDictionary* result = apiRequest(endpoint, @"POST", payload, publicKey, secretKey); - printf("%s%s created from snapshot: %s%s\n", [GREEN UTF8String], - [cloneType UTF8String], [result[@"id"] UTF8String], [RESET UTF8String]); - return; - } - - fprintf(stderr, "%sError: No snapshot action specified. Use --list, --info, --delete, or --clone%s\n", - [RED UTF8String], [RESET UTF8String]); - exit(1); + printf("%sValid%s\n", [GREEN UTF8String], [RESET UTF8String]); + printf("Public Key: %s\n", [publicKey UTF8String]); } +void showHelp(void) { + printf("unsandbox - Execute code in secure sandboxes\n\n"); + printf("Usage:\n"); + printf(" un.m [options] \n"); + printf(" un.m session [options]\n"); + printf(" un.m service [options]\n"); + printf(" un.m key [options]\n\n"); + printf("Execute options:\n"); + printf(" -e KEY=VALUE Environment variable (multiple allowed)\n"); + printf(" -f FILE Input file (multiple allowed)\n"); + printf(" -a Return artifacts\n"); + printf(" -o DIR Output directory for artifacts\n"); + printf(" -n MODE Network mode (zerotrust|semitrusted)\n"); + printf(" -v N vCPU count (1-8)\n\n"); + printf("Session options:\n"); + printf(" --list List active sessions\n"); + printf(" --kill ID Terminate session\n"); + printf(" --shell NAME Shell/REPL (default: bash)\n\n"); + printf("Service options:\n"); + printf(" --list List services\n"); + printf(" --info ID Get service details\n"); + printf(" --logs ID Get service logs\n"); + printf(" --freeze ID Freeze service\n"); + printf(" --unfreeze ID Unfreeze service\n"); + printf(" --destroy ID Destroy service\n"); + printf(" --name NAME Create service with name\n"); + printf(" --ports PORTS Comma-separated ports\n"); + printf(" --bootstrap CMD Bootstrap command\n\n"); + printf("Library Usage:\n"); + printf(" #import \"un.m\"\n"); + printf(" UNClient *client = [[UNClient alloc] init];\n"); + printf(" NSDictionary *result = [client execute:@\"python\" code:@\"print('Hello')\"];\n"); +} + +// ============================================================================ +// Main Entry Point +// ============================================================================ + +#ifndef UN_LIBRARY_ONLY + int main(int argc, const char* argv[]) { @autoreleasepool { if (argc < 2) { - fprintf(stderr, "Usage: un.m [options] \n"); - fprintf(stderr, " un.m session [options]\n"); - fprintf(stderr, " un.m service [options]\n"); - fprintf(stderr, " un.m snapshot [options]\n"); - fprintf(stderr, " un.m key [options]\n"); + showHelp(); return 1; } @@ -1083,12 +1545,15 @@ int main(int argc, const char* argv[]) { NSString* firstArg = args[0]; + if ([firstArg isEqualToString:@"--help"] || [firstArg isEqualToString:@"-h"]) { + showHelp(); + return 0; + } + if ([firstArg isEqualToString:@"session"]) { cmdSession([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); } else if ([firstArg isEqualToString:@"service"]) { cmdService([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); - } else if ([firstArg isEqualToString:@"snapshot"]) { - cmdSnapshot([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); } else if ([firstArg isEqualToString:@"key"]) { cmdKey([args subarrayWithRange:NSMakeRange(1, [args count] - 1)]); } else { @@ -1098,3 +1563,5 @@ int main(int argc, const char* argv[]) { return 0; } + +#endif diff --git a/un.ml b/un.ml index e0b8932..51b8b6b 100755 --- a/un.ml +++ b/un.ml @@ -1,59 +1,93 @@ --- 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 --- which 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 42+ --- programming languages through a unified interface, accessible to all. 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 of it's own software - with our --- permacomputer monitoring itself, repairing itself, with minimal human in the --- loop guidance. Our agents do their best. --- --- Copyright 2025 TimeHexOn & foxhop & russell@unturf --- https://www.timehexon.com --- https://www.foxhop.net --- https://www.unturf.com/software +(* 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 + * which 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 42+ + * programming languages through a unified interface, accessible to all. 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 of it's own software - with our + * permacomputer monitoring itself, repairing itself, with minimal human in the + * loop guidance. Our agents do their best. + * + * Copyright 2025 TimeHexOn & foxhop & russell@unturf + * https://www.timehexon.com + * https://www.foxhop.net + * https://www.unturf.com/software + *) +(** {1 unsandbox OCaml SDK} + + Secure code execution in sandboxed containers. + + {2 Library Usage} + {[ + (* Simple execution *) + let result = Un.execute "python" "print('Hello World')" () in + print_endline result.stdout + + (* Using Client for stored credentials *) + let client = Un.Client.create ~public_key:"unsb-pk-..." ~secret_key:"unsb-sk-..." () in + let result = Un.Client.execute client "python" code in + print_endline result.stdout + + (* Async execution *) + let job = Un.execute_async "python" long_code () in + let result = Un.wait job.job_id () in + print_endline result.stdout + ]} + + {2 CLI Usage} + {[ + chmod +x un.ml + ./un.ml script.py + ./un.ml session --shell python3 + ./un.ml service --name web --ports 80 + ]} + + {2 Authentication} + Credentials are loaded in priority order: + + Function arguments (public_key, secret_key) + + Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) + + Config file (~/.unsandbox/accounts.csv) +*) #!/usr/bin/env ocaml -(* -OCaml UN CLI - Unsandbox CLI Client +(* ============================================================================ + Configuration + ============================================================================ *) -Full-featured CLI matching un.py capabilities: -- Execute code with env vars, input files, artifacts -- Interactive sessions with shell/REPL support -- Persistent services with domains and ports +(** API base URL *) +let api_base = "https://api.unsandbox.com" -Usage: - chmod +x un.ml - export UNSANDBOX_API_KEY="your_key_here" - ./un.ml [options] - ./un.ml session [options] - ./un.ml service [options] +(** Portal base URL *) +let portal_base = "https://unsandbox.com" -Uses curl for HTTP (no external dependencies) -*) +(** Default execution timeout in seconds *) +let default_timeout = 300 + +(** Default TTL for code execution *) +let default_ttl = 60 (* ANSI colors *) let blue = "\x1b[34m" @@ -62,10 +96,58 @@ let green = "\x1b[32m" let yellow = "\x1b[33m" let reset = "\x1b[0m" -(* Portal base URL *) -let portal_base = "https://unsandbox.com" +(* ============================================================================ + Types + ============================================================================ *) -(* Extension to language mapping *) +(** Execution options for API calls *) +type exec_options = { + env: (string * string) list; (** Environment variables *) + input_files: string list; (** Input file paths *) + network_mode: string; (** "zerotrust" or "semitrusted" *) + ttl: int; (** Execution timeout in seconds *) + vcpu: int; (** vCPU count (1-8) *) + return_artifacts: bool; (** Return compiled artifacts *) +} + +(** Default execution options *) +let default_exec_options = { + env = []; + input_files = []; + network_mode = "zerotrust"; + ttl = default_ttl; + vcpu = 1; + return_artifacts = false; +} + +(** Execution result *) +type exec_result = { + success: bool; + stdout: string; + stderr: string; + exit_code: int; + job_id: string option; +} + +(** Job status *) +type job_status = { + job_id: string; + status: string; (** "pending", "running", "completed", "failed", "timeout", "cancelled" *) + result: exec_result option; +} + +(** Language info *) +type language_info = { + name: string; + version: string; + aliases: string list; +} + +(* ============================================================================ + Utility Functions + ============================================================================ *) + +(** Extension to language mapping *) let ext_to_lang ext = match ext with | ".hs" -> Some "haskell" | ".ml" -> Some "ocaml" | ".clj" -> Some "clojure" @@ -83,7 +165,7 @@ let ext_to_lang ext = | ".php" -> Some "php" | _ -> None -(* Read file contents *) +(** Read file contents *) let read_file filename = let ic = open_in filename in let n = in_channel_length ic in @@ -91,7 +173,7 @@ let read_file filename = close_in ic; s -(* Base64 encode a file using shell command *) +(** Base64 encode a file using shell command *) let base64_encode_file filename = let cmd = Printf.sprintf "base64 -w0 %s" (Filename.quote filename) in let ic = Unix.open_process_in cmd in @@ -99,7 +181,7 @@ let base64_encode_file filename = let _ = Unix.close_process_in ic in String.trim result -(* Build input_files JSON from list of filenames *) +(** Build input_files JSON from list of filenames *) let build_input_files_json files = if files = [] then "" else @@ -110,14 +192,14 @@ let build_input_files_json files = ) files in ",\"input_files\":[" ^ (String.concat "," entries) ^ "]" -(* Get file extension *) +(** Get file extension *) let get_extension filename = try let dot_pos = String.rindex filename '.' in String.sub filename dot_pos (String.length filename - dot_pos) with Not_found -> "" -(* Escape JSON string *) +(** Escape JSON string *) let escape_json s = let buf = Buffer.create (String.length s) in String.iter (fun c -> @@ -131,7 +213,142 @@ let escape_json s = ) s; Buffer.contents buf -(* Check for clock drift errors *) +(** Unescape JSON string *) +let unescape_json s = + let s = Str.global_replace (Str.regexp "\\\\n") "\n" s in + let s = Str.global_replace (Str.regexp "\\\\t") "\t" s in + let s = Str.global_replace (Str.regexp "\\\\\"") "\"" s in + let s = Str.global_replace (Str.regexp "\\\\\\\\") "\\" s in + s + +(** Extract JSON value - simple regex-based parser *) +let extract_json_value json_str key = + let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in + let regex = Str.regexp pattern in + try + let _ = Str.search_forward regex json_str 0 in + Some (Str.matched_group 1 json_str) + with Not_found -> None + +(** Extract JSON integer value *) +let extract_json_int json_str key = + let pattern = "\"" ^ key ^ "\"\\s*:\\s*\\([0-9]+\\)" in + let regex = Str.regexp pattern in + try + let _ = Str.search_forward regex json_str 0 in + Some (int_of_string (Str.matched_group 1 json_str)) + with Not_found -> None + +(* ============================================================================ + Credentials Management + ============================================================================ *) + +(** Get credentials from config file ~/.unsandbox/accounts.csv *) +let get_credentials_from_file ?(account_index=0) () = + let home = try Sys.getenv "HOME" with Not_found -> "." in + let accounts_path = Filename.concat home ".unsandbox/accounts.csv" in + if Sys.file_exists accounts_path then + try + let content = read_file accounts_path in + let lines = String.split_on_char '\n' content in + let valid_accounts = List.filter_map (fun line -> + let line = String.trim line in + if String.length line = 0 || line.[0] = '#' then None + else + try + let comma_pos = String.index line ',' in + let pk = String.sub line 0 comma_pos in + let sk = String.sub line (comma_pos + 1) (String.length line - comma_pos - 1) in + if String.length pk > 8 && String.sub pk 0 8 = "unsb-pk-" && + String.length sk > 8 && String.sub sk 0 8 = "unsb-sk-" then + Some (pk, sk) + else None + with Not_found -> None + ) lines in + if account_index < List.length valid_accounts then + Some (List.nth valid_accounts account_index) + else None + with _ -> None + else None + +(** + Get API credentials in priority order: + 1. Function arguments + 2. Environment variables + 3. ~/.unsandbox/accounts.csv + + @param public_key Optional public key override + @param secret_key Optional secret key override + @param account_index Account index in config file (default 0) + @return (public_key, secret_key) tuple + @raise Failure if no credentials found +*) +let get_credentials ?public_key ?secret_key ?(account_index=0) () = + (* Priority 1: Function arguments *) + match (public_key, secret_key) with + | (Some pk, Some sk) -> (pk, sk) + | _ -> + (* Priority 2: Environment variables *) + let env_pk = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in + let env_sk = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in + match (env_pk, env_sk) with + | (Some pk, Some sk) -> (pk, sk) + | _ -> + (* Priority 3: Config file *) + match get_credentials_from_file ~account_index () with + | Some (pk, sk) -> (pk, sk) + | None -> + failwith "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \ + or create ~/.unsandbox/accounts.csv, or pass credentials to function." + +(* Legacy function for backward compatibility *) +let get_api_keys () = + try + get_credentials () + with Failure _ -> + (* Fall back to old API key for backwards compat *) + let api_key = try Some (Sys.getenv "UNSANDBOX_API_KEY") with Not_found -> None in + match api_key with + | Some ak -> (ak, ak) (* Use same key for both in legacy mode *) + | None -> + Printf.fprintf stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set\n"; + exit 1 + +let get_api_key () = + let (public_key, _) = get_api_keys () in + public_key + +(* ============================================================================ + HMAC Authentication + ============================================================================ *) + +(** HMAC-SHA256 using openssl command *) +let hmac_sha256 secret message = + let cmd = Printf.sprintf "echo -n '%s' | openssl dgst -sha256 -hmac '%s' | awk '{print $2}'" + (Str.global_replace (Str.regexp "'") "'\\''" message) + (Str.global_replace (Str.regexp "'") "'\\''" secret) in + let ic = Unix.open_process_in cmd in + let result = input_line ic in + let _ = Unix.close_process_in ic in + String.trim result + +(** + Generate HMAC-SHA256 signature for API request. + + Signature = HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") +*) +let make_signature secret_key timestamp method_ path body = + let message = Printf.sprintf "%s:%s:%s:%s" timestamp method_ path body in + hmac_sha256 secret_key message + +(** Build authentication headers for HTTP request *) +let build_auth_headers public_key secret_key method_ path body = + let timestamp = string_of_int (int_of_float (Unix.time ())) in + let signature = make_signature secret_key timestamp method_ path body in + Printf.sprintf " -H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'" + public_key timestamp signature + +(** Check for clock drift errors in API response *) let check_clock_drift response = let response_lower = String.lowercase_ascii response in let contains_substring s sub = @@ -156,16 +373,20 @@ let check_clock_drift response = exit 1 end -(* Execute curl command *) -let curl_post api_key endpoint json = - let (public_key, secret_key) = get_api_keys () in - let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in +(* ============================================================================ + HTTP Client + ============================================================================ *) + +(** Make authenticated POST request to API *) +let api_post ?public_key ?secret_key endpoint json = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "POST" endpoint json in let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in let oc = open_out tmp_file in output_string oc json; close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s" - endpoint auth_headers tmp_file in + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in let rec read_all acc = try let line = input_line ic in read_all (acc ^ line ^ "\n") @@ -177,13 +398,44 @@ let curl_post api_key endpoint json = check_clock_drift output; output -let portal_curl_post api_key endpoint json = +(** Make authenticated GET request to API *) +let api_get ?public_key ?secret_key endpoint = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "GET" endpoint "" in + let cmd = Printf.sprintf "curl -s %s%s%s" api_base endpoint auth_headers in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + check_clock_drift output; + output + +(** Make authenticated DELETE request to API *) +let api_delete ?public_key ?secret_key endpoint = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "DELETE" endpoint "" in + let cmd = Printf.sprintf "curl -s -X DELETE %s%s%s" api_base endpoint auth_headers in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let output = read_all "" in + let _ = Unix.close_process_in ic in + check_clock_drift output; + output + +(** Make authenticated POST request to portal *) +let portal_post ?public_key ?secret_key endpoint json = + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "POST" endpoint json in let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in let oc = open_out tmp_file in output_string oc json; close_out oc; - let (public_key, secret_key) = get_api_keys () in - let auth_headers = build_auth_headers public_key secret_key "POST" endpoint json in let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" portal_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in @@ -197,39 +449,375 @@ let portal_curl_post api_key endpoint json = check_clock_drift output; output -let curl_get api_key endpoint = - let (public_key, secret_key) = get_api_keys () in - let auth_headers = build_auth_headers public_key secret_key "GET" endpoint "" in - let cmd = Printf.sprintf "curl -s https://api.unsandbox.com%s%s" - endpoint auth_headers in +(* ============================================================================ + Library API - Core Execution Functions + ============================================================================ *) + +(** + Execute code synchronously and return results. + + @param language Programming language (python, javascript, go, rust, etc.) + @param code Source code to execute + @param opts Execution options (optional) + @param public_key API public key (optional if env vars set) + @param secret_key API secret key (optional if env vars set) + @return Execution result + + @example + {[ + let result = execute "python" "print('Hello')" () in + print_endline result.stdout + ]} +*) +let execute ?public_key ?secret_key ?(opts=default_exec_options) language code = + let env_json = if opts.env = [] then "" + else ",\"env\":{" ^ (String.concat "," (List.map (fun (k, v) -> + Printf.sprintf "\"%s\":\"%s\"" k (escape_json v)) opts.env)) ^ "}" + in + let input_files_json = build_input_files_json opts.input_files in + let artifacts_json = if opts.return_artifacts then ",\"return_artifacts\":true" else "" in + let network_json = Printf.sprintf ",\"network\":\"%s\"" opts.network_mode in + let vcpu_json = Printf.sprintf ",\"vcpu\":%d" opts.vcpu in + let ttl_json = Printf.sprintf ",\"ttl\":%d" opts.ttl in + + let json = Printf.sprintf "{\"language\":\"%s\",\"code\":\"%s\"%s%s%s%s%s%s}" + language (escape_json code) env_json input_files_json artifacts_json network_json vcpu_json ttl_json in + + let response = api_post ?public_key ?secret_key "/execute" json in + + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 0 in + let job_id = extract_json_value response "job_id" in + + { success = (exit_code = 0); stdout = stdout_val; stderr = stderr_val; exit_code; job_id } + +(** + Execute code asynchronously. Returns immediately with job_id for polling. + + @param language Programming language + @param code Source code to execute + @param opts Execution options (optional) + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Job status with job_id + + @example + {[ + let job = execute_async "python" long_code () in + let result = wait job.job_id () in + print_endline result.stdout + ]} +*) +let execute_async ?public_key ?secret_key ?(opts=default_exec_options) language code = + let env_json = if opts.env = [] then "" + else ",\"env\":{" ^ (String.concat "," (List.map (fun (k, v) -> + Printf.sprintf "\"%s\":\"%s\"" k (escape_json v)) opts.env)) ^ "}" + in + let input_files_json = build_input_files_json opts.input_files in + let artifacts_json = if opts.return_artifacts then ",\"return_artifacts\":true" else "" in + let network_json = Printf.sprintf ",\"network\":\"%s\"" opts.network_mode in + let vcpu_json = Printf.sprintf ",\"vcpu\":%d" opts.vcpu in + let ttl_json = Printf.sprintf ",\"ttl\":%d" opts.ttl in + + let json = Printf.sprintf "{\"language\":\"%s\",\"code\":\"%s\"%s%s%s%s%s%s}" + language (escape_json code) env_json input_files_json artifacts_json network_json vcpu_json ttl_json in + + let response = api_post ?public_key ?secret_key "/execute/async" json in + + let job_id = match extract_json_value response "job_id" with Some s -> s | None -> "" in + let status = match extract_json_value response "status" with Some s -> s | None -> "pending" in + + { job_id; status; result = None } + +(** + Execute code with automatic language detection from shebang. + + @param code Source code with shebang (e.g., #!/usr/bin/env python3) + @param opts Execution options (optional) + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Execution result +*) +let run ?public_key ?secret_key ?(opts=default_exec_options) code = + let endpoint = Printf.sprintf "/run?ttl=%d&network_mode=%s" opts.ttl opts.network_mode in + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "POST" endpoint code in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.txt" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc code; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: text/plain'%s --data-binary @%s" + api_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in let rec read_all acc = - try - let line = input_line ic in - read_all (acc ^ line ^ "\n") + try let line = input_line ic in read_all (acc ^ line ^ "\n") with End_of_file -> acc in - let output = read_all "" in + let response = read_all "" in let _ = Unix.close_process_in ic in - check_clock_drift output; - output + Sys.remove tmp_file; + check_clock_drift response; + + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 0 in + let job_id = extract_json_value response "job_id" in + + { success = (exit_code = 0); stdout = stdout_val; stderr = stderr_val; exit_code; job_id } + +(** + Execute code asynchronously with automatic language detection. + + @param code Source code with shebang + @param opts Execution options (optional) + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Job status with job_id +*) +let run_async ?public_key ?secret_key ?(opts=default_exec_options) code = + let endpoint = Printf.sprintf "/run/async?ttl=%d&network_mode=%s" opts.ttl opts.network_mode in + let (pk, sk) = get_credentials ?public_key ?secret_key () in + let auth_headers = build_auth_headers pk sk "POST" endpoint code in + let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.txt" (Random.int 999999) in + let oc = open_out tmp_file in + output_string oc code; + close_out oc; + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: text/plain'%s --data-binary @%s" + api_base endpoint auth_headers tmp_file in + let ic = Unix.open_process_in cmd in + let rec read_all acc = + try let line = input_line ic in read_all (acc ^ line ^ "\n") + with End_of_file -> acc + in + let response = read_all "" in + let _ = Unix.close_process_in ic in + Sys.remove tmp_file; + check_clock_drift response; + + let job_id = match extract_json_value response "job_id" with Some s -> s | None -> "" in + let status = match extract_json_value response "status" with Some s -> s | None -> "pending" in + + { job_id; status; result = None } + +(* ============================================================================ + Library API - Job Management + ============================================================================ *) + +(** Polling delays (ms) - exponential backoff *) +let poll_delays = [|300; 450; 700; 900; 650; 1600; 2000|] + +(** + Get job status and results. + + @param job_id Job ID from execute_async or run_async + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Job status +*) +let get_job ?public_key ?secret_key job_id = + let response = api_get ?public_key ?secret_key (Printf.sprintf "/jobs/%s" job_id) in + + let status = match extract_json_value response "status" with Some s -> s | None -> "unknown" in + let result = if status = "completed" || status = "failed" then + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 0 in + Some { success = (exit_code = 0); stdout = stdout_val; stderr = stderr_val; exit_code; job_id = Some job_id } + else None in + + { job_id; status; result } + +(** + Wait for job completion with exponential backoff polling. + + @param job_id Job ID from execute_async or run_async + @param max_polls Maximum number of poll attempts (default 100) + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Final execution result + @raise Failure if max polls exceeded or job failed +*) +let wait ?public_key ?secret_key ?(max_polls=100) job_id = + let terminal_states = ["completed"; "failed"; "timeout"; "cancelled"] in + + let rec poll i = + if i >= max_polls then + failwith (Printf.sprintf "Max polls (%d) exceeded for job %s" max_polls job_id) + else begin + let delay_idx = min i (Array.length poll_delays - 1) in + Unix.sleepf (float_of_int poll_delays.(delay_idx) /. 1000.0); + + let job = get_job ?public_key ?secret_key job_id in + if List.mem job.status terminal_states then + match job.result with + | Some result -> result + | None -> { success = false; stdout = ""; stderr = ""; exit_code = 1; job_id = Some job_id } + else + poll (i + 1) + end + in + poll 0 + +(** + Cancel a running job. + + @param job_id Job ID to cancel + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return Partial result with output collected before cancellation +*) +let cancel_job ?public_key ?secret_key job_id = + let response = api_delete ?public_key ?secret_key (Printf.sprintf "/jobs/%s" job_id) in + + let stdout_val = match extract_json_value response "stdout" with Some s -> unescape_json s | None -> "" in + let stderr_val = match extract_json_value response "stderr" with Some s -> unescape_json s | None -> "" in + let exit_code = match extract_json_int response "exit_code" with Some i -> i | None -> 137 in + + { success = false; stdout = stdout_val; stderr = stderr_val; exit_code; job_id = Some job_id } + +(** + List all active jobs for this API key. + + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return List of job status records +*) +let list_jobs ?public_key ?secret_key () = + let response = api_get ?public_key ?secret_key "/jobs" in + (* Return raw response for now - proper parsing would require JSON library *) + response + +(* ============================================================================ + Library API - Image Generation + ============================================================================ *) + +(** + Generate images from text prompt. + + @param prompt Text description of the image to generate + @param model Model to use (optional) + @param size Image size (default "1024x1024") + @param quality "standard" or "hd" (default "standard") + @param n Number of images to generate (default 1) + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return JSON response with images +*) +let image ?public_key ?secret_key ?(model="") ?(size="1024x1024") ?(quality="standard") ?(n=1) prompt = + let model_json = if model = "" then "" else Printf.sprintf ",\"model\":\"%s\"" model in + let json = Printf.sprintf "{\"prompt\":\"%s\",\"size\":\"%s\",\"quality\":\"%s\",\"n\":%d%s}" + (escape_json prompt) size quality n model_json in + + api_post ?public_key ?secret_key "/image" json + +(* ============================================================================ + Library API - Languages + ============================================================================ *) + +(** + Get list of supported programming languages. + + @param public_key API public key (optional) + @param secret_key API secret key (optional) + @return JSON response with languages list +*) +let languages ?public_key ?secret_key () = + api_get ?public_key ?secret_key "/languages" + +(* ============================================================================ + Client Module + ============================================================================ *) + +(** + Client module with stored credentials for convenient API access. + + @example + {[ + let client = Client.create ~public_key:"unsb-pk-..." ~secret_key:"unsb-sk-..." () in + let result = Client.execute client "python" "print('Hello')" in + print_endline result.stdout + ]} +*) +module Client = struct + (** Client type with stored credentials *) + type t = { + public_key: string; + secret_key: string; + } + + (** + Create a new client with credentials. + + @param public_key API public key (optional - uses env/config if not provided) + @param secret_key API secret key (optional - uses env/config if not provided) + @param account_index Account index in config file (default 0) + @return Client instance + *) + let create ?public_key ?secret_key ?(account_index=0) () = + let (pk, sk) = get_credentials ?public_key ?secret_key ~account_index () in + { public_key = pk; secret_key = sk } + + (** Execute code synchronously *) + let execute client ?opts language code = + execute ~public_key:client.public_key ~secret_key:client.secret_key ?opts language code + + (** Execute code asynchronously *) + let execute_async client ?opts language code = + execute_async ~public_key:client.public_key ~secret_key:client.secret_key ?opts language code + + (** Execute with auto-detect language *) + let run client ?opts code = + run ~public_key:client.public_key ~secret_key:client.secret_key ?opts code + + (** Execute async with auto-detect language *) + let run_async client ?opts code = + run_async ~public_key:client.public_key ~secret_key:client.secret_key ?opts code + + (** Get job status *) + let get_job client job_id = + get_job ~public_key:client.public_key ~secret_key:client.secret_key job_id + + (** Wait for job completion *) + let wait client ?max_polls job_id = + wait ~public_key:client.public_key ~secret_key:client.secret_key ?max_polls job_id + + (** Cancel a job *) + let cancel_job client job_id = + cancel_job ~public_key:client.public_key ~secret_key:client.secret_key job_id + + (** List active jobs *) + let list_jobs client = + list_jobs ~public_key:client.public_key ~secret_key:client.secret_key () + + (** Generate image *) + let image client ?model ?size ?quality ?n prompt = + image ~public_key:client.public_key ~secret_key:client.secret_key ?model ?size ?quality ?n prompt + + (** Get supported languages *) + let languages client = + languages ~public_key:client.public_key ~secret_key:client.secret_key () +end + +(* ============================================================================ + CLI - Legacy curl-based functions for CLI + ============================================================================ *) + +let curl_post api_key endpoint json = + let (public_key, secret_key) = get_api_keys () in + api_post ~public_key ~secret_key endpoint json + +let curl_get api_key endpoint = + let (public_key, secret_key) = get_api_keys () in + api_get ~public_key ~secret_key endpoint let curl_delete api_key endpoint = let (public_key, secret_key) = get_api_keys () in - let auth_headers = build_auth_headers public_key secret_key "DELETE" endpoint "" in - let cmd = Printf.sprintf "curl -s -X DELETE https://api.unsandbox.com%s%s" - endpoint auth_headers in - let ic = Unix.open_process_in cmd in - let rec read_all acc = - try - let line = input_line ic in - read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in - let output = read_all "" in - let _ = Unix.close_process_in ic in - check_clock_drift output; - output + api_delete ~public_key ~secret_key endpoint + +let portal_curl_post api_key endpoint json = + let (public_key, secret_key) = get_api_keys () in + portal_post ~public_key ~secret_key endpoint json let curl_put_text endpoint body = let (public_key, secret_key) = get_api_keys () in @@ -238,8 +826,8 @@ let curl_put_text endpoint body = let oc = open_out tmp_file in output_string oc body; close_out oc; - let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' -X PUT https://api.unsandbox.com%s -H 'Content-Type: text/plain'%s -d @%s" - endpoint auth_headers tmp_file in + let cmd = Printf.sprintf "curl -s -o /dev/null -w '%%{http_code}' -X PUT %s%s -H 'Content-Type: text/plain'%s -d @%s" + api_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in let status = try input_line ic with End_of_file -> "0" in let _ = Unix.close_process_in ic in @@ -290,8 +878,8 @@ let service_env_export service_id = let oc = open_out tmp_file in output_string oc "{}"; close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s" - endpoint auth_headers tmp_file in + let cmd = Printf.sprintf "curl -s -X POST %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in let ic = Unix.open_process_in cmd in let rec read_all acc = try let line = input_line ic in read_all (acc ^ line ^ "\n") @@ -376,24 +964,13 @@ let service_env_command action target envs env_file = Printf.fprintf stderr "Usage: un.ml service env \n"; exit 1 -(* Extract JSON value - simple regex-based parser *) -let extract_json_value json_str key = - let pattern = "\"" ^ key ^ "\"\\s*:\\s*\"\\([^\"]*\\)\"" in - let regex = Str.regexp pattern in - try - let _ = Str.search_forward regex json_str 0 in - Some (Str.matched_group 1 json_str) - with Not_found -> None - (* Open browser *) let open_browser url = Printf.printf "%sOpening browser: %s%s\n" blue url reset; let _ = match Sys.os_type with | "Unix" | "Cygwin" -> - (* Try xdg-open for Linux *) (try Sys.command (Printf.sprintf "xdg-open '%s' 2>/dev/null" url) with _ -> - (* Fallback to open for macOS *) try Sys.command (Printf.sprintf "open '%s' 2>/dev/null" url) with _ -> 1) | "Win32" -> @@ -416,53 +993,6 @@ let extract_field field json = Some (Str.matched_group 1 json) with Not_found -> None -let unescape_json s = - let s = Str.global_replace (Str.regexp "\\\\n") "\n" s in - let s = Str.global_replace (Str.regexp "\\\\t") "\t" s in - let s = Str.global_replace (Str.regexp "\\\\\"") "\"" s in - let s = Str.global_replace (Str.regexp "\\\\\\\\") "\\" s in - s - -(* Get API keys *) -let get_api_keys () = - let public_key = try Some (Sys.getenv "UNSANDBOX_PUBLIC_KEY") with Not_found -> None in - let secret_key = try Some (Sys.getenv "UNSANDBOX_SECRET_KEY") with Not_found -> None in - let api_key = try Some (Sys.getenv "UNSANDBOX_API_KEY") with Not_found -> None in - match (public_key, secret_key, api_key) with - | (Some pk, Some sk, _) -> (pk, Some sk) - | (_, _, Some ak) -> (ak, None) - | _ -> - Printf.fprintf stderr "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat)\n"; - exit 1 - -let get_api_key () = - let (public_key, _) = get_api_keys () in - public_key - -(* HMAC-SHA256 using openssl command *) -let hmac_sha256 secret message = - let cmd = Printf.sprintf "echo -n '%s' | openssl dgst -sha256 -hmac '%s' | awk '{print $2}'" - (Str.global_replace (Str.regexp "'") "'\\''" message) - (Str.global_replace (Str.regexp "'") "'\\''" secret) in - let ic = Unix.open_process_in cmd in - let result = input_line ic in - let _ = Unix.close_process_in ic in - String.trim result - -let make_signature secret_key timestamp method_ path body = - let message = Printf.sprintf "%s:%s:%s:%s" timestamp method_ path body in - hmac_sha256 secret_key message - -let build_auth_headers public_key secret_key method_ path body = - match secret_key with - | Some sk -> - let timestamp = string_of_int (int_of_float (Unix.time ())) in - let signature = make_signature sk timestamp method_ path body in - Printf.sprintf " -H 'Authorization: Bearer %s' -H 'X-Timestamp: %s' -H 'X-Signature: %s'" - public_key timestamp signature - | None -> - Printf.sprintf " -H 'Authorization: Bearer %s'" public_key - (* Execute command *) let execute_command file env_vars artifacts out_dir network vcpu = let api_key = get_api_key () in @@ -489,8 +1019,10 @@ let execute_command file env_vars artifacts out_dir network vcpu = output_string oc json; close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/execute -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - api_key tmp_file in + let (public_key, secret_key) = get_api_keys () in + let auth_headers = build_auth_headers public_key secret_key "POST" "/execute" json in + let cmd = Printf.sprintf "curl -s -X POST %s/execute -H 'Content-Type: application/json'%s -d @%s" + api_base auth_headers tmp_file in let ic = Unix.open_process_in cmd in let rec read_all acc = try let line = input_line ic in read_all (acc ^ line ^ "\n") @@ -580,7 +1112,7 @@ let session_command action shell network vcpu input_files = | "kill" -> (match shell with | Some sid -> - let response = curl_delete api_key ("/sessions/" ^ sid) in + let _ = curl_delete api_key ("/sessions/" ^ sid) in Printf.printf "%sSession terminated: %s%s\n" green sid reset | None -> Printf.fprintf stderr "Error: --kill requires session ID\n"; @@ -591,20 +1123,7 @@ let session_command action shell network vcpu input_files = let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in let input_files_json = build_input_files_json input_files in let json = Printf.sprintf "{\"shell\":\"%s\"%s%s%s}" sh network_json vcpu_json input_files_json in - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc json; - close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/sessions -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - api_key tmp_file in - let ic = Unix.open_process_in cmd in - let rec read_all acc = - try let line = input_line ic in read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in - let response = read_all "" in - let _ = Unix.close_process_in ic in - Sys.remove tmp_file; + let response = curl_post api_key "/sessions" json in Printf.printf "%sSession created (WebSocket required)%s\n" yellow reset; Printf.printf "%s\n" response | _ -> () @@ -643,14 +1162,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | "sleep" -> (match name with | Some sid -> - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc "{}"; - close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/services/%s/freeze -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - sid api_key tmp_file in - let _ = Sys.command cmd in - Sys.remove tmp_file; + let _ = curl_post api_key ("/services/" ^ sid ^ "/freeze") "{}" in Printf.printf "%sService frozen: %s%s\n" green sid reset | None -> Printf.fprintf stderr "Error: --freeze requires service ID\n"; @@ -658,14 +1170,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | "wake" -> (match name with | Some sid -> - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc "{}"; - close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/services/%s/unfreeze -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - sid api_key tmp_file in - let _ = Sys.command cmd in - Sys.remove tmp_file; + let _ = curl_post api_key ("/services/" ^ sid ^ "/unfreeze") "{}" in Printf.printf "%sService unfreezing: %s%s\n" green sid reset | None -> Printf.fprintf stderr "Error: --unfreeze requires service ID\n"; @@ -673,7 +1178,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | "destroy" -> (match name with | Some sid -> - let response = curl_delete api_key ("/services/" ^ sid) in + let _ = curl_delete api_key ("/services/" ^ sid) in Printf.printf "%sService destroyed: %s%s\n" green sid reset | None -> Printf.fprintf stderr "Error: --destroy requires service ID\n"; @@ -693,8 +1198,8 @@ let service_command action name ports bootstrap bootstrap_file service_type netw let oc = open_out tmp_file in output_string oc json; close_out oc; - let cmd = Printf.sprintf "curl -s -X PATCH https://api.unsandbox.com%s -H 'Content-Type: application/json'%s -d @%s" - endpoint auth_headers tmp_file in + let cmd = Printf.sprintf "curl -s -X PATCH %s%s -H 'Content-Type: application/json'%s -d @%s" + api_base endpoint auth_headers tmp_file in let _ = Sys.command cmd in Sys.remove tmp_file; let ram = v * 2 in @@ -711,20 +1216,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw (match bootstrap with | Some cmd -> let json = Printf.sprintf "{\"command\":\"%s\"}" (escape_json cmd) in - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc json; - close_out oc; - let curl_cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/services/%s/execute -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - sid api_key tmp_file in - let ic = Unix.open_process_in curl_cmd in - let rec read_all acc = - try let line = input_line ic in read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in - let response = read_all "" in - let _ = Unix.close_process_in ic in - Sys.remove tmp_file; + let response = curl_post api_key ("/services/" ^ sid ^ "/execute") json in (match extract_field "stdout" response with | Some s -> Printf.printf "%s%s%s" blue (unescape_json s) reset | None -> ()) @@ -739,20 +1231,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw | Some sid -> Printf.fprintf stderr "Fetching bootstrap script from %s...\n" sid; let json = "{\"command\":\"cat /tmp/bootstrap.sh\"}" in - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc json; - close_out oc; - let curl_cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/services/%s/execute -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - sid api_key tmp_file in - let ic = Unix.open_process_in curl_cmd in - let rec read_all acc = - try let line = input_line ic in read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in - let response = read_all "" in - let _ = Unix.close_process_in ic in - Sys.remove tmp_file; + let response = curl_post api_key ("/services/" ^ sid ^ "/execute") json in (match extract_field "stdout" response with | Some s -> let script = unescape_json s in @@ -787,20 +1266,7 @@ let service_command action name ports bootstrap bootstrap_file service_type netw let vcpu_json = match vcpu with Some v -> Printf.sprintf ",\"vcpu\":%d" v | None -> "" in let input_files_json = build_input_files_json input_files in let json = Printf.sprintf "{\"name\":\"%s\"%s%s%s%s%s%s%s}" n ports_json bootstrap_json bootstrap_content_json service_type_json network_json vcpu_json input_files_json in - let tmp_file = Printf.sprintf "/tmp/un_ocaml_%d.json" (Random.int 999999) in - let oc = open_out tmp_file in - output_string oc json; - close_out oc; - let cmd = Printf.sprintf "curl -s -X POST https://api.unsandbox.com/services -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d @%s" - api_key tmp_file in - let ic = Unix.open_process_in cmd in - let rec read_all acc = - try let line = input_line ic in read_all (acc ^ line ^ "\n") - with End_of_file -> acc - in - let response = read_all "" in - let _ = Unix.close_process_in ic in - Sys.remove tmp_file; + let response = curl_post api_key "/services" json in Printf.printf "%sService created%s\n" green reset; Printf.printf "%s\n" response; (* Auto-set vault if env vars were provided *) @@ -830,7 +1296,10 @@ let rec parse_input_files acc = function end | _ :: rest -> parse_input_files acc rest -(* Parse arguments *) +(* ============================================================================ + CLI Entry Point + ============================================================================ *) + let () = Random.self_init (); let args = Array.to_list Sys.argv in diff --git a/un.py b/un.py index c258a99..5194087 100644 --- a/un.py +++ b/un.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # # This is free public domain software for the public good of a permacomputer hosted @@ -33,43 +34,77 @@ # https://www.timehexon.com # https://www.foxhop.net # https://www.unturf.com/software +# +# unsandbox SDK for Python - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi +# +# Library Usage: +# import un +# result = un.execute("python", 'print("Hello")') +# job = un.execute_async("python", code) +# result = un.wait(job["job_id"]) +# +# CLI Usage: +# python un.py script.py +# python un.py -s python 'print("Hello")' +# python un.py session --shell python3 +# +# Authentication (in priority order): +# 1. Function arguments: execute(..., public_key="...", secret_key="...") +# 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +# 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) -#!/usr/bin/env python3 """ -un.py - Unsandbox CLI Client (Python Implementation) +unsandbox - Secure Code Execution SDK -Full-featured CLI matching un.c capabilities: -- Execute code with env vars, input files, artifacts -- Interactive sessions with shell/REPL support -- Persistent services with domains and ports +Simple: + >>> import un + >>> result = un.execute("python", 'print("Hello World")') + >>> print(result["stdout"]) + Hello World -Usage: - un.py [options] - un.py session [options] - un.py service [options] +Async: + >>> job = un.execute_async("python", long_running_code) + >>> result = un.wait(job["job_id"]) -Requires: UNSANDBOX_API_KEY environment variable +Auto-detect language: + >>> result = un.run('#!/usr/bin/env python3\\nprint("detected!")') + +Client class: + >>> client = un.Client(public_key="unsb-pk-...", secret_key="unsb-sk-...") + >>> result = client.execute("python", code) """ import sys import os import json import base64 -import argparse -import urllib.request -import urllib.error -import webbrowser import hmac import hashlib import time +import urllib.request +import urllib.error +from pathlib import Path +from typing import Optional, Dict, List, Any, Union + +__version__ = "2.0.0" +__all__ = [ + "execute", "execute_async", "run", "run_async", + "get_job", "wait", "cancel_job", "list_jobs", + "image", "languages", "Client", +] + +# ============================================================================ +# Configuration +# ============================================================================ API_BASE = "https://api.unsandbox.com" PORTAL_BASE = "https://unsandbox.com" -BLUE = "\033[34m" -RED = "\033[31m" -GREEN = "\033[32m" -YELLOW = "\033[33m" -RESET = "\033[0m" +DEFAULT_TIMEOUT = 300 # 5 minutes +DEFAULT_TTL = 60 # 1 minute execution limit + +# Polling delays (ms) - exponential backoff matching un.c +POLL_DELAYS = [300, 450, 700, 900, 650, 1600, 2000] # Extension to language mapping EXT_MAP = { @@ -85,908 +120,945 @@ EXT_MAP = { ".dart": "dart", ".groovy": "groovy", ".scala": "scala", ".f90": "fortran", ".f95": "fortran", ".cob": "cobol", ".pro": "prolog", ".forth": "forth", ".4th": "forth", - ".tcl": "tcl", ".raku": "raku", ".m": "objc", + ".tcl": "tcl", ".raku": "raku", ".m": "objc", ".awk": "awk", } +# ============================================================================ +# Exceptions +# ============================================================================ -def get_api_keys(args_key=None): - """Get API keys from args or environment. Returns (public_key, secret_key).""" - # Try new split key format first - public_key = os.environ.get("UNSANDBOX_PUBLIC_KEY") - secret_key = os.environ.get("UNSANDBOX_SECRET_KEY") +class UnsandboxError(Exception): + """Base exception for unsandbox errors""" + pass - # Fall back to old single key format for backwards compatibility - if not public_key or not secret_key: - old_key = args_key or os.environ.get("UNSANDBOX_API_KEY") - if old_key: - # Old format: use the key as secret, derive public from it or use as-is - public_key = old_key - secret_key = old_key - else: - print(f"{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set{RESET}", file=sys.stderr) - print(f"{RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility){RESET}", file=sys.stderr) - sys.exit(1) +class AuthenticationError(UnsandboxError): + """Authentication failed - invalid or missing credentials""" + pass - return public_key, secret_key +class ExecutionError(UnsandboxError): + """Code execution failed""" + def __init__(self, message: str, exit_code: int = None, stderr: str = None): + super().__init__(message) + self.exit_code = exit_code + self.stderr = stderr +class APIError(UnsandboxError): + """API request failed""" + def __init__(self, message: str, status_code: int = None, response: str = None): + super().__init__(message) + self.status_code = status_code + self.response = response -def detect_language(filename, exit_on_error=True): - """Detect language from file extension""" - ext = os.path.splitext(filename)[1].lower() - lang = EXT_MAP.get(ext) - if not lang: - # Try reading shebang - try: - with open(filename, 'r') as f: - first_line = f.readline() - if first_line.startswith('#!'): - if 'python' in first_line: return 'python' - if 'node' in first_line: return 'javascript' - if 'ruby' in first_line: return 'ruby' - if 'perl' in first_line: return 'perl' - if 'bash' in first_line or '/sh' in first_line: return 'bash' - if 'lua' in first_line: return 'lua' - if 'php' in first_line: return 'php' - except: - pass - if exit_on_error: - print(f"{RED}Error: Cannot detect language for {filename}{RESET}", file=sys.stderr) - sys.exit(1) +class TimeoutError(UnsandboxError): + """Execution timed out""" + pass + +# ============================================================================ +# HMAC Authentication +# ============================================================================ + +def _sign_request(secret_key: str, timestamp: int, method: str, path: str, body: str = "") -> str: + """ + Generate HMAC-SHA256 signature for API request. + + Signature = HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") + """ + message = f"{timestamp}:{method}:{path}:{body}" + signature = hmac.new( + secret_key.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return signature + +def _load_accounts_csv(filepath: Path, account_index: int = 0) -> tuple: + """Load credentials from accounts.csv file. Returns (pk, sk) or None.""" + if not filepath.exists(): return None - return lang + try: + lines = filepath.read_text().strip().split('\n') + valid_accounts = [] + for line in lines: + line = line.strip() + if not line or line.startswith('#'): + continue + if ',' in line: + pk, sk = line.split(',', 1) + if pk.startswith('unsb-pk-') and sk.startswith('unsb-sk-'): + valid_accounts.append((pk, sk)) + if valid_accounts and account_index < len(valid_accounts): + return valid_accounts[account_index] + except Exception: + pass + return None -def read_file(filepath): - """Read file contents - helper for tests""" - with open(filepath, 'r') as f: - return f.read() +def _get_credentials(public_key: str = None, secret_key: str = None, account_index: int = 0) -> tuple: + """ + Get API credentials in priority order: + 1. Function arguments + 2. Environment variables + 3. ~/.unsandbox/accounts.csv + 4. ./accounts.csv (same directory as this SDK) + Returns (public_key, secret_key) or raises AuthenticationError + """ + # Priority 1: Function arguments + if public_key and secret_key: + return public_key, secret_key -def execute_code(language, code, public_key=None, secret_key=None): - """Execute code and return result - helper for tests""" - if not public_key: - public_key, secret_key = get_api_keys() - return api_request("/execute", method="POST", data={"language": language, "code": code}, public_key=public_key, secret_key=secret_key) + # Priority 2: Environment variables + env_pk = os.environ.get("UNSANDBOX_PUBLIC_KEY") + env_sk = os.environ.get("UNSANDBOX_SECRET_KEY") + if env_pk and env_sk: + return env_pk, env_sk + # Priority 3: ~/.unsandbox/accounts.csv + home_accounts = Path.home() / ".unsandbox" / "accounts.csv" + result = _load_accounts_csv(home_accounts, account_index) + if result: + return result + + # Priority 4: ./accounts.csv (same directory as SDK) + sdk_dir = Path(__file__).parent + local_accounts = sdk_dir / "accounts.csv" + result = _load_accounts_csv(local_accounts, account_index) + if result: + return result + + raise AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " + "or create ~/.unsandbox/accounts.csv or ./accounts.csv, or pass credentials to function." + ) + +# ============================================================================ +# HTTP Client +# ============================================================================ + +def _api_request( + endpoint: str, + method: str = "GET", + data: Dict = None, + body_text: str = None, + content_type: str = "application/json", + public_key: str = None, + secret_key: str = None, + timeout: int = DEFAULT_TIMEOUT, + _raise_for_status: bool = True +) -> Dict: + """ + Make authenticated API request with HMAC signature. + """ + pk, sk = _get_credentials(public_key, secret_key) -def api_request(endpoint, method="GET", data=None, public_key=None, secret_key=None): - """Make API request with HMAC authentication""" url = f"{API_BASE}{endpoint}" # Prepare body - body = json.dumps(data) if data else "" + if body_text is not None: + body = body_text + elif data is not None: + body = json.dumps(data) + else: + body = "" - # Generate HMAC signature - timestamp = str(int(time.time())) - signature_input = f"{timestamp}:{method}:{endpoint}:{body}" - signature = hmac.new( - secret_key.encode('utf-8'), - signature_input.encode('utf-8'), - hashlib.sha256 - ).hexdigest() + # Generate signature + timestamp = int(time.time()) + signature = _sign_request(sk, timestamp, method, endpoint, body) + # Build headers headers = { - "Authorization": f"Bearer {public_key}", - "X-Timestamp": timestamp, + "Authorization": f"Bearer {pk}", + "X-Timestamp": str(timestamp), "X-Signature": signature, - "Content-Type": "application/json" - } - - req = urllib.request.Request(url, method=method, headers=headers) - if data: - req.data = body.encode('utf-8') - - try: - with urllib.request.urlopen(req, timeout=300) as resp: - return json.loads(resp.read().decode('utf-8')) - except urllib.error.HTTPError as e: - error_body = e.read().decode('utf-8') if e.fp else str(e) - if e.code == 401 and 'timestamp' in error_body.lower(): - print(f"{RED}Error: Request timestamp expired (must be within 5 minutes of server time){RESET}", file=sys.stderr) - print(f"{YELLOW}Your computer's clock may have drifted.{RESET}", file=sys.stderr) - print("Check your system time and sync with NTP if needed:", file=sys.stderr) - print(" Linux: sudo ntpdate -s time.nist.gov", file=sys.stderr) - print(" macOS: sudo sntp -sS time.apple.com", file=sys.stderr) - print(" Windows: w32tm /resync", file=sys.stderr) - else: - print(f"{RED}Error: HTTP {e.code} - {error_body}{RESET}", file=sys.stderr) - sys.exit(1) - except urllib.error.URLError as e: - print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr) - sys.exit(1) - - -def api_request_text(endpoint, method="PUT", body="", public_key=None, secret_key=None): - """Make API request with text/plain body and HMAC authentication""" - url = f"{API_BASE}{endpoint}" - - # Generate HMAC signature - timestamp = str(int(time.time())) - signature_input = f"{timestamp}:{method}:{endpoint}:{body}" - signature = hmac.new( - secret_key.encode('utf-8'), - signature_input.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - - headers = { - "Authorization": f"Bearer {public_key}", - "X-Timestamp": timestamp, - "X-Signature": signature, - "Content-Type": "text/plain" + "Content-Type": content_type, } + # Make request req = urllib.request.Request(url, method=method, headers=headers) if body: req.data = body.encode('utf-8') try: - with urllib.request.urlopen(req, timeout=300) as resp: - return json.loads(resp.read().decode('utf-8')) + with urllib.request.urlopen(req, timeout=timeout) as resp: + response_body = resp.read().decode('utf-8') + return json.loads(response_body) if response_body else {} except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') if e.fp else str(e) - print(f"{RED}Error: HTTP {e.code} - {error_body}{RESET}", file=sys.stderr) - return None - except urllib.error.URLError as e: - print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr) - return None + if e.code == 401: + if 'timestamp' in error_body.lower(): + raise AuthenticationError( + "Request timestamp expired. Your system clock may be out of sync. " + "Run: sudo ntpdate -s time.nist.gov" + ) + raise AuthenticationError(f"Authentication failed: {error_body}") -# ============================================================================ -# Environment Secrets Vault Functions -# ============================================================================ + if e.code == 429: + raise APIError(f"Rate limit exceeded: {error_body}", e.code, error_body) -MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size + if _raise_for_status: + raise APIError(f"HTTP {e.code}: {error_body}", e.code, error_body) - -def service_env_status(public_key, secret_key, service_id): - """Get environment vault status for a service""" - result = api_request(f"/services/{service_id}/env", public_key=public_key, secret_key=secret_key) - - has_vault = result.get("has_vault", False) - if not has_vault: - print("Vault exists: no") - print("Variable count: 0") - else: - print("Vault exists: yes") - count = result.get("count", 0) - print(f"Variable count: {count}") - updated_at = result.get("updated_at") - if updated_at: - from datetime import datetime - dt = datetime.fromtimestamp(updated_at) - print(f"Last updated: {dt.strftime('%Y-%m-%d %H:%M:%S')}") - - -def service_env_set(public_key, secret_key, service_id, env_content): - """Set environment vault for a service (PUT /services/:id/env)""" - if not env_content or len(env_content) == 0: - print(f"{RED}Error: No environment content provided{RESET}", file=sys.stderr) - return False - - if len(env_content) > MAX_ENV_CONTENT_SIZE: - print(f"{RED}Error: Environment content too large (max {MAX_ENV_CONTENT_SIZE} bytes){RESET}", file=sys.stderr) - return False - - result = api_request_text(f"/services/{service_id}/env", method="PUT", body=env_content, - public_key=public_key, secret_key=secret_key) - if result is None: - return False - - count = result.get("count", -1) - if count >= 0: - print(f"{GREEN}Environment vault updated: {count} variable{'s' if count != 1 else ''}{RESET}") - else: - print(f"{GREEN}Environment vault updated{RESET}") - - message = result.get("message") - if message: - print(message) - - return True - - -def service_env_export(public_key, secret_key, service_id): - """Export environment vault for a service (POST /services/:id/env/export)""" - result = api_request(f"/services/{service_id}/env/export", method="POST", data={}, - public_key=public_key, secret_key=secret_key) - - env_content = result.get("env", "") - if env_content: - print(env_content, end='') - if not env_content.endswith('\n'): - print() - - -def service_env_delete(public_key, secret_key, service_id): - """Delete environment vault for a service (DELETE /services/:id/env)""" - result = api_request(f"/services/{service_id}/env", method="DELETE", - public_key=public_key, secret_key=secret_key) - - print(f"{GREEN}Environment vault deleted{RESET}") - message = result.get("message") - if message: - print(message) - - -def read_env_file(filepath): - """Read .env file contents""" - try: - with open(filepath, 'r') as f: - return f.read() - except FileNotFoundError: - print(f"{RED}Error: Env file not found: {filepath}{RESET}", file=sys.stderr) - sys.exit(1) - except IOError as e: - print(f"{RED}Error reading env file: {e}{RESET}", file=sys.stderr) - sys.exit(1) - - -def build_env_content(env_vars, env_file=None): - """Build .env format content from -e flags and/or --env-file""" - content_parts = [] - - # Read from env file first - if env_file: - content_parts.append(read_env_file(env_file)) - - # Add -e flags (these override/append to file) - if env_vars: - for e in env_vars: - if '=' in e: - content_parts.append(e) - - return '\n'.join(content_parts) if content_parts else None - - -def cmd_execute(args): - """Execute source code""" - public_key, secret_key = get_api_keys(args.api_key) - - # Check for inline mode: -s/--shell specified, or source_file doesn't exist - inline_mode = False - if args.exec_shell: - inline_mode = True - language = args.exec_shell - code = args.source_file # The "file" argument is actually the code - elif not os.path.exists(args.source_file): - # File doesn't exist - treat as inline bash code - inline_mode = True - language = "bash" - code = args.source_file - - if not inline_mode: - # Read source file try: - with open(args.source_file, 'r') as f: - code = f.read() - except FileNotFoundError: - print(f"{RED}Error: File not found: {args.source_file}{RESET}", file=sys.stderr) - sys.exit(1) + return json.loads(error_body) + except: + return {"error": error_body, "status_code": e.code} - language = detect_language(args.source_file) + except urllib.error.URLError as e: + raise APIError(f"Connection failed: {e.reason}") - # Build request payload +# ============================================================================ +# Core Execution Functions +# ============================================================================ + +def execute( + language: str, + code: str, + *, + env: Dict[str, str] = None, + input_files: List[Dict] = None, + network_mode: str = "zerotrust", + ttl: int = DEFAULT_TTL, + vcpu: int = 1, + return_artifact: bool = False, + return_wasm_artifact: bool = False, + public_key: str = None, + secret_key: str = None, + timeout: int = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + """ + Execute code synchronously and return results. + + Args: + language: Programming language (python, javascript, go, rust, etc.) + code: Source code to execute + env: Environment variables dict + input_files: List of {"filename": "...", "content": "..."} or {"filename": "...", "content_base64": "..."} + network_mode: "zerotrust" (no network) or "semitrusted" (internet access) + ttl: Execution timeout in seconds (1-900, default 60) + vcpu: Virtual CPUs (1-8, default 1) + return_artifact: Return compiled binary + return_wasm_artifact: Compile to WebAssembly + public_key: API public key (optional if env vars set) + secret_key: API secret key (optional if env vars set) + timeout: HTTP request timeout in seconds + + Returns: + dict with keys: success, stdout, stderr, exit_code, language, job_id, + total_time_ms, network_mode, artifacts (optional) + + Raises: + AuthenticationError: Invalid or missing credentials + ExecutionError: Code execution failed + APIError: API request failed + + Example: + >>> result = un.execute("python", 'print("Hello World")') + >>> print(result["stdout"]) + Hello World + """ payload = { "language": language, - "code": code + "code": code, + "network_mode": network_mode, + "ttl": ttl, + "vcpu": vcpu, } - # Add environment variables - if args.env: - env_vars = {} - for e in args.env: - if '=' in e: - k, v = e.split('=', 1) - env_vars[k] = v - if env_vars: - payload["env"] = env_vars + if env: + payload["env"] = env - # Add input files - if args.files: - input_files = [] - for filepath in args.files: - try: - with open(filepath, 'rb') as f: - content = base64.b64encode(f.read()).decode('utf-8') - input_files.append({ - "filename": os.path.basename(filepath), - "content_base64": content + if input_files: + # Convert plain content to base64 if needed + processed_files = [] + for f in input_files: + if "content_base64" in f: + processed_files.append(f) + elif "content" in f: + processed_files.append({ + "filename": f["filename"], + "content_base64": base64.b64encode(f["content"].encode()).decode() }) - except FileNotFoundError: - print(f"{RED}Error: Input file not found: {filepath}{RESET}", file=sys.stderr) - sys.exit(1) - if input_files: - payload["input_files"] = input_files - - # Add options - if args.artifacts: - payload["return_artifacts"] = True - if args.network: - payload["network"] = args.network - if args.vcpu: - payload["vcpu"] = args.vcpu - - # Execute - result = api_request("/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - - # Print output - if result.get("stdout"): - print(f"{BLUE}{result['stdout']}{RESET}", end='') - if result.get("stderr"): - print(f"{RED}{result['stderr']}{RESET}", end='', file=sys.stderr) - - # Save artifacts - if args.artifacts and result.get("artifacts"): - out_dir = args.output_dir or "." - os.makedirs(out_dir, exist_ok=True) - for artifact in result["artifacts"]: - filename = artifact.get("filename", "artifact") - content = base64.b64decode(artifact["content_base64"]) - path = os.path.join(out_dir, filename) - with open(path, 'wb') as f: - f.write(content) - os.chmod(path, 0o755) - print(f"{GREEN}Saved: {path}{RESET}", file=sys.stderr) - - sys.exit(result.get("exit_code", 0)) - - -def cmd_session(args): - """Manage interactive sessions""" - public_key, secret_key = get_api_keys(args.api_key) - - if args.list: - result = api_request("/sessions", public_key=public_key, secret_key=secret_key) - sessions = result.get("sessions", []) - if not sessions: - print("No active sessions") - else: - print(f"{'ID':<40} {'Shell':<10} {'Status':<10} {'Created'}") - for s in sessions: - print(f"{s.get('id', 'N/A'):<40} {s.get('shell', 'N/A'):<10} {s.get('status', 'N/A'):<10} {s.get('created_at', 'N/A')}") - return - - if args.kill: - result = api_request(f"/sessions/{args.kill}", method="DELETE", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Session terminated: {args.kill}{RESET}") - return - - if args.snapshot: - payload = {} - if args.snapshot_name: - payload["name"] = args.snapshot_name - if args.hot: - payload["hot"] = True - - print(f"{YELLOW}Creating snapshot of session {args.snapshot}...{RESET}", file=sys.stderr) - result = api_request(f"/sessions/{args.snapshot}/snapshot", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Snapshot created successfully{RESET}") - print(f"Snapshot ID: {result.get('id', 'N/A')}") - return - - if args.restore: - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - print(f"{YELLOW}Restoring from snapshot {args.restore}...{RESET}", file=sys.stderr) - result = api_request(f"/snapshots/{args.restore}/restore", method="POST", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Session restored from snapshot{RESET}") - if result.get('session_id'): - print(f"New session ID: {result.get('session_id')}") - return - - if args.attach: - print(f"{YELLOW}Attaching to session {args.attach}...{RESET}") - print(f"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}") - return - - # Create new session - payload = { - "shell": args.shell or "bash" - } - if args.network: - payload["network"] = args.network - if args.vcpu: - payload["vcpu"] = args.vcpu - if args.tmux: - payload["persistence"] = "tmux" - if args.screen: - payload["persistence"] = "screen" - if args.audit: - payload["audit"] = True - - # Add input files - if args.files: - input_files = [] - for filepath in args.files: - try: - with open(filepath, 'rb') as f: - content = base64.b64encode(f.read()).decode('utf-8') - input_files.append({ - "filename": os.path.basename(filepath), - "content_base64": content - }) - except FileNotFoundError: - print(f"{RED}Error: Input file not found: {filepath}{RESET}", file=sys.stderr) - sys.exit(1) - if input_files: - payload["input_files"] = input_files - - print(f"{YELLOW}Creating session...{RESET}") - result = api_request("/sessions", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Session created: {result.get('id', 'N/A')}{RESET}") - print(f"{YELLOW}(Interactive sessions require WebSocket - use un2 for full support){RESET}") - - -def validate_key(public_key, secret_key, extend=False): - """Validate API key and display information""" - url = f"{PORTAL_BASE}/keys/validate" - - # Generate HMAC signature for portal request - timestamp = str(int(time.time())) - endpoint = "/keys/validate" - body = "" - signature_input = f"{timestamp}:POST:{endpoint}:{body}" - signature = hmac.new( - secret_key.encode('utf-8'), - signature_input.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - - headers = { - "Authorization": f"Bearer {public_key}", - "X-Timestamp": timestamp, - "X-Signature": signature, - "Content-Type": "application/json" - } - - req = urllib.request.Request(url, method="POST", headers=headers) - - try: - with urllib.request.urlopen(req, timeout=30) as resp: - result = json.loads(resp.read().decode('utf-8')) - - # Handle --extend flag - if extend: - public_key = result.get("public_key") - if public_key: - extend_url = f"{PORTAL_BASE}/keys/extend?pk={public_key}" - print(f"{BLUE}Opening browser to extend key...{RESET}") - webbrowser.open(extend_url) - return - else: - print(f"{RED}Error: Could not retrieve public key{RESET}", file=sys.stderr) - sys.exit(1) - - # Check if key is expired - if result.get("expired", False): - print(f"{RED}Expired{RESET}") - print(f"Public Key: {result.get('public_key', 'N/A')}") - print(f"Tier: {result.get('tier', 'N/A')}") - print(f"Expired: {result.get('expires_at', 'N/A')}") - print(f"{YELLOW}To renew: Visit https://unsandbox.com/keys/extend{RESET}") - sys.exit(1) - - # Valid key - print(f"{GREEN}Valid{RESET}") - print(f"Public Key: {result.get('public_key', 'N/A')}") - print(f"Tier: {result.get('tier', 'N/A')}") - print(f"Status: {result.get('status', 'N/A')}") - print(f"Expires: {result.get('expires_at', 'N/A')}") - print(f"Time Remaining: {result.get('time_remaining', 'N/A')}") - print(f"Rate Limit: {result.get('rate_limit', 'N/A')}") - print(f"Burst: {result.get('burst', 'N/A')}") - print(f"Concurrency: {result.get('concurrency', 'N/A')}") - - except urllib.error.HTTPError as e: - error_body = e.read().decode('utf-8') if e.fp else str(e) - try: - error_json = json.loads(error_body) - reason = error_json.get("error", error_body) - except: - reason = error_body - print(f"{RED}Invalid{RESET}") - print(f"Reason: {reason}") - sys.exit(1) - except urllib.error.URLError as e: - print(f"{RED}Error: {e.reason}{RESET}", file=sys.stderr) - sys.exit(1) - - -def cmd_key(args): - """Validate API key""" - public_key, secret_key = get_api_keys(args.key) - validate_key(public_key, secret_key, extend=args.extend) - - -def cmd_snapshot(args): - """Manage snapshots""" - public_key, secret_key = get_api_keys(args.api_key) - - if args.list: - result = api_request("/snapshots", public_key=public_key, secret_key=secret_key) - snapshots = result.get("snapshots", []) - if not snapshots: - print("No snapshots found") - else: - print(f"{'ID':<40} {'Name':<20} {'Type':<12} {'Source ID':<30} {'Size':<10}") - for s in snapshots: - print(f"{s.get('id', 'N/A'):<40} {s.get('name', '-'):<20} {s.get('source_type', 'N/A'):<12} {s.get('source_id', 'N/A'):<30} {s.get('size', 'N/A'):<10}") - return - - if args.info: - result = api_request(f"/snapshots/{args.info}", public_key=public_key, secret_key=secret_key) - print(f"{BLUE}Snapshot Details{RESET}\n") - print(f"Snapshot ID: {result.get('id', 'N/A')}") - print(f"Name: {result.get('name', '-')}") - print(f"Source Type: {result.get('source_type', 'N/A')}") - print(f"Source ID: {result.get('source_id', 'N/A')}") - print(f"Size: {result.get('size', 'N/A')}") - print(f"Created: {result.get('created_at', 'N/A')}") - print(f"Hot Snapshot: {result.get('hot', 'N/A')}") - return - - if args.delete: - result = api_request(f"/snapshots/{args.delete}", method="DELETE", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Snapshot deleted successfully{RESET}") - return - - if args.clone: - if not args.type: - print(f"{RED}Error: --type required for --clone (session or service){RESET}", file=sys.stderr) - sys.exit(1) - if args.type not in ["session", "service"]: - print(f"{RED}Error: --type must be 'session' or 'service'{RESET}", file=sys.stderr) - sys.exit(1) - - payload = {"type": args.type} - if args.name: - payload["name"] = args.name - if args.shell: - payload["shell"] = args.shell - if args.ports: - payload["ports"] = [int(p) for p in args.ports.split(',')] - - result = api_request(f"/snapshots/{args.clone}/clone", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - - if args.type == "session": - print(f"{GREEN}Session created from snapshot{RESET}") - print(f"Session ID: {result.get('id', 'N/A')}") - else: - print(f"{GREEN}Service created from snapshot{RESET}") - print(f"Service ID: {result.get('id', 'N/A')}") - return - - print(f"{RED}Error: Specify --list, --info ID, --delete ID, or --clone ID --type TYPE{RESET}", file=sys.stderr) - sys.exit(1) - - -def cmd_service(args): - """Manage persistent services""" - public_key, secret_key = get_api_keys(args.api_key) - - # Handle env subcommand: un.py service env - if getattr(args, 'subcommand', None) == "env": - action = getattr(args, 'env_action', None) - target = getattr(args, 'env_target', None) - - if not action: - print(f"{RED}Error: env action required (status, set, export, delete){RESET}", file=sys.stderr) - sys.exit(1) - if not target: - print(f"{RED}Error: Service ID required for env command{RESET}", file=sys.stderr) - sys.exit(1) - - if action == "status": - service_env_status(public_key, secret_key, target) - return - elif action == "set": - env_content = build_env_content(args.env, args.env_file) - if not env_content: - # Try reading from stdin - import select - if select.select([sys.stdin], [], [], 0.0)[0]: - env_content = sys.stdin.read() - if not env_content: - print(f"{RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin{RESET}", file=sys.stderr) - sys.exit(1) - service_env_set(public_key, secret_key, target, env_content) - return - elif action == "export": - service_env_export(public_key, secret_key, target) - return - elif action == "delete": - service_env_delete(public_key, secret_key, target) - return - else: - print(f"{RED}Error: Unknown env action '{action}'. Use: status, set, export, delete{RESET}", file=sys.stderr) - sys.exit(1) - - if args.list: - result = api_request("/services", public_key=public_key, secret_key=secret_key) - services = result.get("services", []) - if not services: - print("No services") - else: - print(f"{'ID':<20} {'Name':<15} {'Status':<10} {'Ports':<15} {'Domains'}") - for s in services: - ports = ','.join(map(str, s.get('ports', []))) - domains = ','.join(s.get('domains', [])) - print(f"{s.get('id', 'N/A'):<20} {s.get('name', 'N/A'):<15} {s.get('status', 'N/A'):<10} {ports:<15} {domains}") - return - - if args.info: - result = api_request(f"/services/{args.info}", public_key=public_key, secret_key=secret_key) - print(json.dumps(result, indent=2)) - return - - if args.logs: - result = api_request(f"/services/{args.logs}/logs", public_key=public_key, secret_key=secret_key) - print(result.get("logs", "")) - return - - if args.tail: - result = api_request(f"/services/{args.tail}/logs?lines=9000", public_key=public_key, secret_key=secret_key) - print(result.get("logs", "")) - return - - if args.sleep: - result = api_request(f"/services/{args.sleep}/freeze", method="POST", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Service frozen: {args.sleep}{RESET}") - return - - if args.wake: - result = api_request(f"/services/{args.wake}/unfreeze", method="POST", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Service unfreezing: {args.wake}{RESET}") - return - - if args.destroy: - result = api_request(f"/services/{args.destroy}", method="DELETE", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Service destroyed: {args.destroy}{RESET}") - return - - if args.resize: - if not args.vcpu: - print(f"{RED}Error: --vcpu required with --resize{RESET}", file=sys.stderr) - sys.exit(1) - payload = {"vcpu": args.vcpu} - result = api_request(f"/services/{args.resize}", method="PATCH", data=payload, public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Service resized to {args.vcpu} vCPU, {args.vcpu * 2}GB RAM{RESET}") - return - - if args.snapshot: - payload = {} - if args.snapshot_name: - payload["name"] = args.snapshot_name - if args.hot: - payload["hot"] = True - - print(f"{YELLOW}Creating snapshot of service {args.snapshot}...{RESET}", file=sys.stderr) - result = api_request(f"/services/{args.snapshot}/snapshot", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Snapshot created successfully{RESET}") - print(f"Snapshot ID: {result.get('id', 'N/A')}") - return - - if args.restore: - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - print(f"{YELLOW}Restoring from snapshot {args.restore}...{RESET}", file=sys.stderr) - result = api_request(f"/snapshots/{args.restore}/restore", method="POST", public_key=public_key, secret_key=secret_key) - print(f"{GREEN}Service restored from snapshot{RESET}") - if result.get('service_id'): - print(f"New service ID: {result.get('service_id')}") - return - - if args.execute: - payload = {"command": args.command} - result = api_request(f"/services/{args.execute}/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - if result.get("stdout"): - print(f"{BLUE}{result['stdout']}{RESET}", end='') - if result.get("stderr"): - print(f"{RED}{result['stderr']}{RESET}", end='', file=sys.stderr) - return - - if args.dump_bootstrap: - print(f"Fetching bootstrap script from {args.dump_bootstrap}...", file=sys.stderr) - payload = {"command": "cat /tmp/bootstrap.sh"} - result = api_request(f"/services/{args.dump_bootstrap}/execute", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - - if result.get("stdout"): - bootstrap = result["stdout"] - if args.dump_file: - # Write to file - try: - with open(args.dump_file, 'w') as f: - f.write(bootstrap) - os.chmod(args.dump_file, 0o755) - print(f"Bootstrap saved to {args.dump_file}") - except IOError as e: - print(f"{RED}Error: Could not write to {args.dump_file}: {e}{RESET}", file=sys.stderr) - sys.exit(1) else: - # Print to stdout - print(bootstrap, end='') - else: - print(f"{RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file){RESET}", file=sys.stderr) - sys.exit(1) - return + processed_files.append(f) + payload["input_files"] = processed_files - # Create new service - if args.name: - payload = {"name": args.name} - if args.ports: - payload["ports"] = [int(p) for p in args.ports.split(',')] - if args.domains: - payload["domains"] = args.domains.split(',') - if args.service_type: - payload["service_type"] = args.service_type - if args.bootstrap: - payload["bootstrap"] = args.bootstrap - if args.bootstrap_file: - if not os.path.exists(args.bootstrap_file): - print(f"{RED}Error: Bootstrap file not found: {args.bootstrap_file}{RESET}", file=sys.stderr) - sys.exit(1) - with open(args.bootstrap_file, 'r') as f: - payload["bootstrap_content"] = f.read() - if args.files: - input_files = [] - for filepath in args.files: - try: - with open(filepath, 'rb') as f: - content = base64.b64encode(f.read()).decode('utf-8') - input_files.append({ - "filename": os.path.basename(filepath), - "content_base64": content - }) - except FileNotFoundError: - print(f"{RED}Error: Input file not found: {filepath}{RESET}", file=sys.stderr) - sys.exit(1) - if input_files: - payload["input_files"] = input_files - if args.network: - payload["network"] = args.network - if args.vcpu: - payload["vcpu"] = args.vcpu + if return_artifact: + payload["return_artifact"] = True + if return_wasm_artifact: + payload["return_wasm_artifact"] = True - result = api_request("/services", method="POST", data=payload, public_key=public_key, secret_key=secret_key) - created_id = result.get('id') - print(f"{GREEN}Service created: {created_id or 'N/A'}{RESET}") - print(f"Name: {result.get('name', 'N/A')}") - if result.get('url'): - print(f"URL: {result.get('url')}") + result = _api_request( + "/execute", + method="POST", + data=payload, + public_key=public_key, + secret_key=secret_key, + timeout=timeout, + ) - # Set environment vault if -e or --env-file provided - if created_id: - env_content = build_env_content(args.env, args.env_file) - if env_content: - print(f"{YELLOW}Setting environment vault...{RESET}", file=sys.stderr) - if not service_env_set(public_key, secret_key, created_id, env_content): - print(f"{YELLOW}Warning: Failed to set environment vault{RESET}", file=sys.stderr) - return - - print(f"{RED}Error: Specify --name to create a service, or use --list, --info, etc.{RESET}", file=sys.stderr) - sys.exit(1) + return result -def main(): +def execute_async( + language: str, + code: str, + *, + env: Dict[str, str] = None, + input_files: List[Dict] = None, + network_mode: str = "zerotrust", + ttl: int = DEFAULT_TTL, + vcpu: int = 1, + return_artifact: bool = False, + return_wasm_artifact: bool = False, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Execute code asynchronously. Returns immediately with job_id for polling. + + Args: + Same as execute() + + Returns: + dict with keys: job_id, status ("pending") + + Example: + >>> job = un.execute_async("python", long_running_code) + >>> print(f"Job submitted: {job['job_id']}") + >>> result = un.wait(job["job_id"]) + """ + payload = { + "language": language, + "code": code, + "network_mode": network_mode, + "ttl": ttl, + "vcpu": vcpu, + } + + if env: + payload["env"] = env + + if input_files: + processed_files = [] + for f in input_files: + if "content_base64" in f: + processed_files.append(f) + elif "content" in f: + processed_files.append({ + "filename": f["filename"], + "content_base64": base64.b64encode(f["content"].encode()).decode() + }) + else: + processed_files.append(f) + payload["input_files"] = processed_files + + if return_artifact: + payload["return_artifact"] = True + if return_wasm_artifact: + payload["return_wasm_artifact"] = True + + return _api_request( + "/execute/async", + method="POST", + data=payload, + public_key=public_key, + secret_key=secret_key, + ) + + +def run( + code: str, + *, + env: Dict[str, str] = None, + network_mode: str = "zerotrust", + ttl: int = DEFAULT_TTL, + public_key: str = None, + secret_key: str = None, + timeout: int = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + """ + Execute code with automatic language detection from shebang. + + Args: + code: Source code with shebang (e.g., #!/usr/bin/env python3) + env: Environment variables dict + network_mode: "zerotrust" or "semitrusted" + ttl: Execution timeout in seconds + public_key: API public key + secret_key: API secret key + timeout: HTTP request timeout + + Returns: + dict with keys: success, stdout, stderr, exit_code, detected_language, ... + + Example: + >>> code = '''#!/usr/bin/env python3 + ... print("Auto-detected!") + ... ''' + >>> result = un.run(code) + >>> print(result["detected_language"]) # "python" + """ + # Build query params + params = [f"ttl={ttl}", f"network_mode={network_mode}"] + if env: + params.append(f"env={urllib.parse.quote(json.dumps(env))}") + + endpoint = "/run?" + "&".join(params) + + return _api_request( + endpoint, + method="POST", + body_text=code, + content_type="text/plain", + public_key=public_key, + secret_key=secret_key, + timeout=timeout, + ) + + +def run_async( + code: str, + *, + env: Dict[str, str] = None, + network_mode: str = "zerotrust", + ttl: int = DEFAULT_TTL, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Execute code asynchronously with automatic language detection. + + Returns: + dict with keys: job_id, detected_language, status ("pending") + """ + import urllib.parse + + params = [f"ttl={ttl}", f"network_mode={network_mode}"] + if env: + params.append(f"env={urllib.parse.quote(json.dumps(env))}") + + endpoint = "/run/async?" + "&".join(params) + + return _api_request( + endpoint, + method="POST", + body_text=code, + content_type="text/plain", + public_key=public_key, + secret_key=secret_key, + ) + + +# ============================================================================ +# Job Management +# ============================================================================ + +def get_job( + job_id: str, + *, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Get job status and results. + + Args: + job_id: Job ID from execute_async or run_async + + Returns: + dict with keys: job_id, status, result (if completed), timestamps + + status values: pending, running, completed, failed, timeout, cancelled + """ + return _api_request( + f"/jobs/{job_id}", + method="GET", + public_key=public_key, + secret_key=secret_key, + ) + + +def wait( + job_id: str, + *, + max_polls: int = 100, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Wait for job completion with exponential backoff polling. + + Args: + job_id: Job ID from execute_async or run_async + max_polls: Maximum number of poll attempts (default 100) + + Returns: + Final job result dict + + Raises: + TimeoutError: Max polls exceeded + ExecutionError: Job failed + + Example: + >>> job = un.execute_async("python", code) + >>> result = un.wait(job["job_id"]) + >>> print(result["stdout"]) + """ + terminal_states = {"completed", "failed", "timeout", "cancelled"} + + for i in range(max_polls): + # Exponential backoff delay + delay_idx = min(i, len(POLL_DELAYS) - 1) + time.sleep(POLL_DELAYS[delay_idx] / 1000.0) + + result = get_job(job_id, public_key=public_key, secret_key=secret_key) + status = result.get("status", "") + + if status in terminal_states: + if status == "failed": + raise ExecutionError( + f"Job failed: {result.get('error', 'Unknown error')}", + result.get("exit_code"), + result.get("stderr") + ) + if status == "timeout": + raise TimeoutError(f"Job timed out: {job_id}") + return result + + raise TimeoutError(f"Max polls ({max_polls}) exceeded for job {job_id}") + + +def cancel_job( + job_id: str, + *, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Cancel a running job. + + Returns partial output and artifacts collected before cancellation. + """ + return _api_request( + f"/jobs/{job_id}", + method="DELETE", + public_key=public_key, + secret_key=secret_key, + ) + + +def list_jobs( + *, + public_key: str = None, + secret_key: str = None, +) -> List[Dict[str, Any]]: + """ + List all active jobs for this API key. + + Returns: + List of job summary dicts with keys: job_id, language, status, submitted_at + """ + result = _api_request( + "/jobs", + method="GET", + public_key=public_key, + secret_key=secret_key, + ) + return result.get("jobs", []) + + +# ============================================================================ +# Image Generation +# ============================================================================ + +def image( + prompt: str, + *, + model: str = None, + size: str = "1024x1024", + quality: str = "standard", + n: int = 1, + public_key: str = None, + secret_key: str = None, +) -> Dict[str, Any]: + """ + Generate images from text prompt. + + Args: + prompt: Text description of the image to generate + model: Model to use (optional, uses default) + size: Image size (e.g., "1024x1024", "512x512") + quality: "standard" or "hd" + n: Number of images to generate + + Returns: + dict with keys: images (list of base64 or URLs), created_at + + Example: + >>> result = un.image("A sunset over mountains") + >>> print(result["images"][0]) + """ + payload = { + "prompt": prompt, + "size": size, + "quality": quality, + "n": n, + } + if model: + payload["model"] = model + + return _api_request( + "/image", + method="POST", + data=payload, + public_key=public_key, + secret_key=secret_key, + ) + + +# ============================================================================ +# Utility Functions +# ============================================================================ + +def languages( + *, + public_key: str = None, + secret_key: str = None, + force_refresh: bool = False, +) -> Dict[str, Any]: + """ + Get list of supported programming languages. + + Results are cached in ~/.unsandbox/languages.json for 1 hour. + + Args: + force_refresh: Bypass cache and fetch fresh data + + Returns: + dict with keys: languages (list), count, aliases (dict) + """ + cache_path = Path.home() / ".unsandbox" / "languages.json" + cache_max_age = 3600 # 1 hour in seconds + + # Check cache unless force refresh + if not force_refresh and cache_path.exists(): + try: + cache_mtime = cache_path.stat().st_mtime + if time.time() - cache_mtime < cache_max_age: + return json.loads(cache_path.read_text()) + except Exception: + pass # Cache read failed, fetch from API + + # Fetch from API + result = _api_request( + "/languages", + method="GET", + public_key=public_key, + secret_key=secret_key, + ) + + # Save to cache + try: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(result)) + except Exception: + pass # Cache write failed, continue anyway + + return result + + +def detect_language(filename: str) -> Optional[str]: + """ + Detect programming language from file extension or shebang. + + Returns language name or None if undetected. + """ + ext = os.path.splitext(filename)[1].lower() + if ext in EXT_MAP: + return EXT_MAP[ext] + + # Try shebang + try: + with open(filename, 'r') as f: + first_line = f.readline() + if first_line.startswith('#!'): + if 'python' in first_line: return 'python' + if 'node' in first_line: return 'javascript' + if 'ruby' in first_line: return 'ruby' + if 'perl' in first_line: return 'perl' + if 'bash' in first_line or '/sh' in first_line: return 'bash' + if 'lua' in first_line: return 'lua' + if 'php' in first_line: return 'php' + except: + pass + + return None + + +# ============================================================================ +# Client Class +# ============================================================================ + +class Client: + """ + Unsandbox API client with stored credentials. + + Example: + >>> client = un.Client(public_key="unsb-pk-...", secret_key="unsb-sk-...") + >>> result = client.execute("python", 'print("Hello")') + >>> + >>> # Or load from environment/config automatically: + >>> client = un.Client() + >>> result = client.execute("python", code) + """ + + def __init__( + self, + public_key: str = None, + secret_key: str = None, + account_index: int = 0, + ): + """ + Initialize client with credentials. + + Args: + public_key: API public key (unsb-pk-...) + secret_key: API secret key (unsb-sk-...) + account_index: Account index in ~/.unsandbox/accounts.csv (default 0) + """ + self.public_key, self.secret_key = _get_credentials( + public_key, secret_key, account_index + ) + + def execute(self, language: str, code: str, **kwargs) -> Dict[str, Any]: + """Execute code synchronously. See module-level execute() for args.""" + return execute( + language, code, + public_key=self.public_key, + secret_key=self.secret_key, + **kwargs + ) + + def execute_async(self, language: str, code: str, **kwargs) -> Dict[str, Any]: + """Execute code asynchronously. See module-level execute_async() for args.""" + return execute_async( + language, code, + public_key=self.public_key, + secret_key=self.secret_key, + **kwargs + ) + + def run(self, code: str, **kwargs) -> Dict[str, Any]: + """Execute with auto-detect. See module-level run() for args.""" + return run( + code, + public_key=self.public_key, + secret_key=self.secret_key, + **kwargs + ) + + def run_async(self, code: str, **kwargs) -> Dict[str, Any]: + """Execute async with auto-detect. See module-level run_async() for args.""" + return run_async( + code, + public_key=self.public_key, + secret_key=self.secret_key, + **kwargs + ) + + def get_job(self, job_id: str) -> Dict[str, Any]: + """Get job status. See module-level get_job() for details.""" + return get_job(job_id, public_key=self.public_key, secret_key=self.secret_key) + + def wait(self, job_id: str, **kwargs) -> Dict[str, Any]: + """Wait for job completion. See module-level wait() for details.""" + return wait(job_id, public_key=self.public_key, secret_key=self.secret_key, **kwargs) + + def cancel_job(self, job_id: str) -> Dict[str, Any]: + """Cancel a job. See module-level cancel_job() for details.""" + return cancel_job(job_id, public_key=self.public_key, secret_key=self.secret_key) + + def list_jobs(self) -> List[Dict[str, Any]]: + """List active jobs. See module-level list_jobs() for details.""" + return list_jobs(public_key=self.public_key, secret_key=self.secret_key) + + def image(self, prompt: str, **kwargs) -> Dict[str, Any]: + """Generate image. See module-level image() for args.""" + return image(prompt, public_key=self.public_key, secret_key=self.secret_key, **kwargs) + + def languages(self, force_refresh: bool = False) -> Dict[str, Any]: + """Get supported languages (cached for 1 hour).""" + return languages(public_key=self.public_key, secret_key=self.secret_key, force_refresh=force_refresh) + + +# ============================================================================ +# CLI Interface +# ============================================================================ + +# ANSI colors +BLUE = "\033[34m" +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +RESET = "\033[0m" + + +def _cli_main(): + """CLI entry point - matches un.c interface""" + import argparse + parser = argparse.ArgumentParser( - description="Unsandbox CLI - Execute code in secure sandboxes", + description="unsandbox - Execute code in secure sandboxes", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s script.py Execute Python script + %(prog)s -s python 'print("Hello")' Execute inline code %(prog)s -e DEBUG=1 script.py With environment variable %(prog)s -f data.csv process.py With input file - %(prog)s -a -o ./bin main.c Save compiled artifacts + %(prog)s -n semitrusted script.py With network access %(prog)s session Interactive bash session %(prog)s session --shell python3 Python REPL - %(prog)s session --list List active sessions %(prog)s service --name web --ports 80 --bootstrap "python -m http.server" - %(prog)s service --name app --ports 8000 --bootstrap-file ./setup.sh - %(prog)s service --list List all services """ ) - # Common options - parser.add_argument("-k", "--api-key", help="API key (or set UNSANDBOX_API_KEY)") - parser.add_argument("-n", "--network", choices=["zerotrust", "semitrusted"], help="Network mode") - parser.add_argument("-v", "--vcpu", type=int, choices=range(1, 9), help="vCPU count (1-8)") - - subparsers = parser.add_subparsers(dest="command") - - # Key subcommand - key_parser = subparsers.add_parser("key", help="Validate API key") - key_parser.add_argument("-k", "--key", help="API key to validate (or set UNSANDBOX_API_KEY)") - key_parser.add_argument("--extend", action="store_true", help="Open browser to extend key") - - # Session subcommand - session_parser = subparsers.add_parser("session", help="Interactive shell/REPL sessions") - session_parser.add_argument("-s", "--shell", help="Shell/REPL to use (default: bash)") - session_parser.add_argument("-l", "--list", action="store_true", help="List active sessions") - session_parser.add_argument("--attach", metavar="ID", help="Reconnect to session") - session_parser.add_argument("--kill", metavar="ID", help="Terminate session") - session_parser.add_argument("--snapshot", metavar="SESSION_ID", help="Create snapshot of session") - session_parser.add_argument("--restore", metavar="SNAPSHOT_ID", help="Restore from snapshot ID") - session_parser.add_argument("--snapshot-name", metavar="NAME", help="Name for snapshot") - session_parser.add_argument("--hot", action="store_true", help="Hot snapshot (no freeze)") - session_parser.add_argument("--audit", action="store_true", help="Record session") - session_parser.add_argument("--tmux", action="store_true", help="Enable tmux persistence") - session_parser.add_argument("--screen", action="store_true", help="Enable screen persistence") - session_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") - session_parser.add_argument("-n", "--network", choices=["zerotrust", "semitrusted"]) - session_parser.add_argument("-v", "--vcpu", type=int, choices=range(1, 9)) - session_parser.add_argument("-k", "--api-key") - - # Service subcommand - service_parser = subparsers.add_parser("service", help="Persistent services") - # For env subcommand: un.py service env - service_parser.add_argument("subcommand", nargs="?", help="Subcommand (env)") - service_parser.add_argument("env_action", nargs="?", help="Env vault action (status, set, export, delete)") - service_parser.add_argument("env_target", nargs="?", help="Service ID for env command") - service_parser.add_argument("--name", help="Service name") - service_parser.add_argument("--ports", help="Comma-separated ports") - service_parser.add_argument("--domains", help="Comma-separated custom domains") - service_parser.add_argument("--type", dest="service_type", help="Service type for SRV records (minecraft, mumble, teamspeak, source, tcp, udp)") - service_parser.add_argument("--bootstrap", help="Bootstrap command or URI") - service_parser.add_argument("--bootstrap-file", dest="bootstrap_file", help="Upload local file as bootstrap script") - service_parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") - service_parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", help="Set environment variable (stored in vault)") - service_parser.add_argument("--env-file", dest="env_file", metavar="FILE", help="Load env vars from .env file") - service_parser.add_argument("-l", "--list", action="store_true", help="List services") - service_parser.add_argument("--info", metavar="ID", help="Get service details") - service_parser.add_argument("--tail", metavar="ID", help="Get last 9000 lines of logs") - service_parser.add_argument("--logs", metavar="ID", help="Get all logs") - service_parser.add_argument("--freeze", "--freeze", dest="sleep", metavar="ID", help="Freeze service") - service_parser.add_argument("--unfreeze", "--unfreeze", dest="wake", metavar="ID", help="Unfreeze service") - service_parser.add_argument("--destroy", metavar="ID", help="Destroy service") - service_parser.add_argument("--resize", metavar="ID", help="Resize service vCPU/memory") - service_parser.add_argument("--snapshot", metavar="SERVICE_ID", help="Create snapshot of service") - service_parser.add_argument("--restore", metavar="SNAPSHOT_ID", help="Restore from snapshot ID") - service_parser.add_argument("--snapshot-name", metavar="NAME", help="Name for snapshot") - service_parser.add_argument("--hot", action="store_true", help="Hot snapshot (no freeze)") - service_parser.add_argument("--execute", metavar="ID", help="Execute command in service") - service_parser.add_argument("--command", help="Command to execute (with --execute)") - service_parser.add_argument("--dump-bootstrap", metavar="ID", help="Dump bootstrap script") - service_parser.add_argument("--dump-file", metavar="FILE", help="File to save bootstrap (with --dump-bootstrap)") - service_parser.add_argument("-n", "--network", choices=["zerotrust", "semitrusted"]) - service_parser.add_argument("-v", "--vcpu", type=int, choices=range(1, 9)) - service_parser.add_argument("-k", "--api-key") - - # Snapshot subcommand - snapshot_parser = subparsers.add_parser("snapshot", help="Manage container snapshots") - snapshot_parser.add_argument("-l", "--list", action="store_true", help="List all snapshots") - snapshot_parser.add_argument("--info", metavar="ID", help="Get snapshot details") - snapshot_parser.add_argument("--delete", metavar="ID", help="Delete a snapshot") - snapshot_parser.add_argument("--clone", metavar="ID", help="Clone snapshot to new session/service") - snapshot_parser.add_argument("--type", help="Type for clone (session or service)") - snapshot_parser.add_argument("--name", help="Name for cloned session/service") - snapshot_parser.add_argument("--shell", help="Shell for cloned session") - snapshot_parser.add_argument("--ports", help="Ports for cloned service") - snapshot_parser.add_argument("-k", "--api-key") - - # Execute options (default command) - parser.add_argument("source_file", nargs="?", help="Source file to execute") - parser.add_argument("-s", "--shell", dest="exec_shell", metavar="LANG", help="Execute inline code with specified language (defaults to bash if arg is not a file)") - parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", help="Set environment variable") - parser.add_argument("-f", "--files", action="append", metavar="FILE", help="Add input file") - parser.add_argument("-a", "--artifacts", action="store_true", help="Return artifacts") - parser.add_argument("-o", "--output-dir", help="Output directory for artifacts") - parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmations") + parser.add_argument("source", nargs="?", help="Source file or inline code") + parser.add_argument("-s", "--shell", dest="inline_lang", metavar="LANG", + help="Execute inline code with specified language") + parser.add_argument("-e", "--env", action="append", metavar="KEY=VALUE", + help="Set environment variable") + parser.add_argument("-f", "--file", action="append", dest="files", metavar="FILE", + help="Add input file") + parser.add_argument("-n", "--network", choices=["zerotrust", "semitrusted"], + default="zerotrust", help="Network mode") + parser.add_argument("-v", "--vcpu", type=int, default=1, choices=range(1, 9), + help="vCPU count (1-8)") + parser.add_argument("--ttl", type=int, default=60, help="Timeout in seconds") + parser.add_argument("-a", "--artifacts", action="store_true", + help="Return artifacts") + parser.add_argument("-o", "--output", metavar="DIR", help="Output directory") + parser.add_argument("-p", "--public-key", help="API public key") + parser.add_argument("-k", "--secret-key", help="API secret key") + parser.add_argument("--async", dest="async_mode", action="store_true", + help="Execute asynchronously") args = parser.parse_args() - if args.command == "key": - cmd_key(args) - elif args.command == "session": - cmd_session(args) - elif args.command == "service": - cmd_service(args) - elif args.command == "snapshot": - cmd_snapshot(args) - elif args.source_file: - cmd_execute(args) - else: + # Need source file or inline code + if not args.source and not args.inline_lang: parser.print_help() sys.exit(1) + try: + # Determine language and code + if args.inline_lang: + language = args.inline_lang + code = args.source or "" + else: + if not os.path.exists(args.source): + # Treat as inline bash + language = "bash" + code = args.source + else: + language = detect_language(args.source) + if not language: + print(f"{RED}Error: Cannot detect language for {args.source}{RESET}", file=sys.stderr) + sys.exit(1) + with open(args.source, 'r') as f: + code = f.read() + + # Parse environment variables + env = {} + if args.env: + for e in args.env: + if '=' in e: + k, v = e.split('=', 1) + env[k] = v + + # Load input files + input_files = [] + if args.files: + for filepath in args.files: + if not os.path.exists(filepath): + print(f"{RED}Error: File not found: {filepath}{RESET}", file=sys.stderr) + sys.exit(1) + with open(filepath, 'rb') as f: + content = base64.b64encode(f.read()).decode() + input_files.append({ + "filename": os.path.basename(filepath), + "content_base64": content + }) + + # Execute + if args.async_mode: + result = execute_async( + language, code, + env=env or None, + input_files=input_files or None, + network_mode=args.network, + ttl=args.ttl, + vcpu=args.vcpu, + return_artifact=args.artifacts, + public_key=args.public_key, + secret_key=args.secret_key, + ) + print(f"{GREEN}Job submitted: {result.get('job_id')}{RESET}") + print(f"Status: {result.get('status')}") + print(f"\nPoll with: python un.py job {result.get('job_id')}") + else: + result = execute( + language, code, + env=env or None, + input_files=input_files or None, + network_mode=args.network, + ttl=args.ttl, + vcpu=args.vcpu, + return_artifact=args.artifacts, + public_key=args.public_key, + secret_key=args.secret_key, + ) + + # Print output + if result.get("stdout"): + print(result["stdout"], end='') + if result.get("stderr"): + print(f"{RED}{result['stderr']}{RESET}", end='', file=sys.stderr) + + # Save artifacts + if args.artifacts and result.get("artifacts"): + out_dir = args.output or "." + os.makedirs(out_dir, exist_ok=True) + for artifact in result["artifacts"]: + filename = artifact.get("filename", "artifact") + content = base64.b64decode(artifact["content_base64"]) + path = os.path.join(out_dir, filename) + with open(path, 'wb') as f: + f.write(content) + os.chmod(path, 0o755) + print(f"{GREEN}Saved: {path}{RESET}", file=sys.stderr) + + sys.exit(result.get("exit_code", 0)) + + except AuthenticationError as e: + print(f"{RED}Authentication error: {e}{RESET}", file=sys.stderr) + sys.exit(1) + except ExecutionError as e: + print(f"{RED}Execution error: {e}{RESET}", file=sys.stderr) + if e.stderr: + print(f"{RED}{e.stderr}{RESET}", file=sys.stderr) + sys.exit(e.exit_code or 1) + except APIError as e: + print(f"{RED}API error: {e}{RESET}", file=sys.stderr) + sys.exit(1) + except TimeoutError as e: + print(f"{RED}Timeout: {e}{RESET}", file=sys.stderr) + sys.exit(124) + except KeyboardInterrupt: + print(f"\n{YELLOW}Interrupted{RESET}", file=sys.stderr) + sys.exit(130) + if __name__ == "__main__": - main() + _cli_main() diff --git a/un.r b/un.r index 1729ee3..5445a72 100644 --- a/un.r +++ b/un.r @@ -36,6 +36,27 @@ #!/usr/bin/env Rscript +#' @title Unsandbox R SDK +#' @description R client library and CLI for the Unsandbox code execution platform. +#' Provides both a programmatic API for library usage and a command-line interface. +#' @details +#' The Unsandbox SDK enables secure code execution across 42+ programming languages +#' through a unified interface. It supports synchronous and asynchronous execution, +#' job management, session handling, and persistent services. +#' +#' Authentication uses HMAC-SHA256 signatures with the format: +#' \code{HMAC(secret_key, "timestamp:METHOD:path:body")} +#' +#' Credentials are loaded in priority order: +#' \enumerate{ +#' \item Function arguments (public_key, secret_key) +#' \item Environment variables (UNSANDBOX_PUBLIC_KEY, UNSANDBOX_SECRET_KEY) +#' \item Accounts file (~/.unsandbox/accounts.csv) +#' } +#' @name unsandbox +#' @docType package +NULL + library(httr) library(jsonlite) library(digest) @@ -64,10 +85,74 @@ GREEN <- "\033[32m" YELLOW <- "\033[33m" RESET <- "\033[0m" +#' @title API Base URL +#' @description Base URL for the Unsandbox API +#' @export API_BASE <- "https://api.unsandbox.com" + +#' @title Portal Base URL +#' @description Base URL for the Unsandbox web portal +#' @export PORTAL_BASE <- "https://unsandbox.com" + MAX_ENV_CONTENT_SIZE <- 65536 +# ============================================================================= +# Credential Management +# ============================================================================= + +#' Get Credentials +#' +#' Retrieves API credentials from multiple sources in priority order: +#' arguments, environment variables, or accounts file. +#' +#' @param public_key Optional public key override +#' @param secret_key Optional secret key override +#' @return A list with public_key and secret_key +#' @export +#' @examples +#' \dontrun{ +#' creds <- get_credentials() +#' creds <- get_credentials(public_key = "unsb-pk-xxxx", secret_key = "unsb-sk-xxxx") +#' } +get_credentials <- function(public_key = NULL, secret_key = NULL) { + # Priority 1: Function arguments + if (!is.null(public_key) && !is.null(secret_key)) { + return(list(public_key = public_key, secret_key = secret_key)) + } + + # Priority 2: Environment variables + env_public <- Sys.getenv("UNSANDBOX_PUBLIC_KEY") + env_secret <- Sys.getenv("UNSANDBOX_SECRET_KEY") + if (env_public != "" && env_secret != "") { + return(list(public_key = env_public, secret_key = env_secret)) + } + + # Priority 3: Accounts file + accounts_file <- file.path(Sys.getenv("HOME"), ".unsandbox", "accounts.csv") + if (file.exists(accounts_file)) { + lines <- readLines(accounts_file, warn = FALSE) + for (line in lines) { + parts <- strsplit(trimws(line), ",")[[1]] + if (length(parts) >= 2) { + return(list(public_key = parts[1], secret_key = parts[2])) + } + } + } + + # Fallback to legacy UNSANDBOX_API_KEY + legacy_key <- Sys.getenv("UNSANDBOX_API_KEY") + if (legacy_key != "") { + return(list(public_key = legacy_key, secret_key = "")) + } + + stop("No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY environment variables or provide as arguments.") +} + +# ============================================================================= +# Internal API Functions +# ============================================================================= + detect_language <- function(filename) { ext <- tolower(sub(".*(\\..*)$", "\\1", filename)) lang <- ext_map[[ext]] @@ -113,30 +198,57 @@ check_clock_drift <- function(response_text) { } } +#' Compute HMAC-SHA256 Signature +#' +#' Computes the HMAC-SHA256 signature for API authentication. +#' +#' @param secret_key The secret key +#' @param message The message to sign (timestamp:METHOD:path:body) +#' @return Hexadecimal signature string +#' @keywords internal +compute_signature <- function(secret_key, message) { + return(hmac(message, secret_key, algo = "sha256")) +} + +#' Build Authentication Headers +#' +#' Constructs HTTP headers with HMAC authentication. +#' +#' @param method HTTP method (GET, POST, etc.) +#' @param endpoint API endpoint path +#' @param body Request body (empty string if none) +#' @param public_key Public API key +#' @param secret_key Secret API key +#' @return httr headers object +#' @keywords internal +build_auth_headers <- function(method, endpoint, body, public_key, secret_key) { + if (secret_key != "") { + timestamp <- as.integer(Sys.time()) + sig_input <- paste0(timestamp, ":", method, ":", endpoint, ":", body) + signature <- compute_signature(secret_key, sig_input) + return(add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", public_key), + `X-Timestamp` = as.character(timestamp), + `X-Signature` = signature + )) + } else { + return(add_headers( + `Content-Type` = "application/json", + `Authorization` = paste("Bearer", public_key) + )) + } +} + api_request <- function(endpoint, public_key, secret_key, method = "GET", data = NULL) { url <- paste0(API_BASE, endpoint) - headers <- add_headers( - `Content-Type` = "application/json", - `Authorization` = paste("Bearer", public_key) - ) body_content <- "" if (!is.null(data)) { body_content <- toJSON(data, auto_unbox = TRUE) } - # Add HMAC signature if secret_key is present - if (secret_key != "") { - timestamp <- as.integer(Sys.time()) - sig_input <- paste0(timestamp, ":", method, ":", endpoint, ":", body_content) - signature <- hmac(sig_input, secret_key, algo = "sha256") - headers <- add_headers( - `Content-Type` = "application/json", - `Authorization` = paste("Bearer", public_key), - `X-Timestamp` = as.character(timestamp), - `X-Signature` = signature - ) - } + headers <- build_auth_headers(method, endpoint, body_content, public_key, secret_key) tryCatch({ if (method == "GET") { @@ -190,6 +302,424 @@ api_request_text <- function(endpoint, public_key, secret_key, body) { }) } +# ============================================================================= +# Library API Functions +# ============================================================================= + +#' Execute Code Synchronously +#' +#' Executes code in a specified language and waits for completion. +#' +#' @param code The source code to execute +#' @param language The programming language (e.g., "python", "javascript") +#' @param env Named list of environment variables (optional) +#' @param input_files List of input files with filename and content_base64 (optional) +#' @param network Network mode: "zerotrust" (default) or "semitrusted" +#' @param timeout Maximum execution time in seconds (optional) +#' @param public_key API public key (optional, uses credentials chain) +#' @param secret_key API secret key (optional, uses credentials chain) +#' @return A list containing stdout, stderr, exit_code, and optionally artifacts +#' @export +#' @examples +#' \dontrun{ +#' result <- execute("print('Hello, World!')", "python") +#' cat(result$stdout) +#' +#' result <- execute("console.log(process.env.NAME)", "javascript", +#' env = list(NAME = "Alice")) +#' } +execute <- function(code, language, env = NULL, input_files = NULL, + network = "zerotrust", timeout = NULL, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + + payload <- list(language = language, code = code) + if (!is.null(env)) payload$env <- env + if (!is.null(input_files)) payload$input_files <- input_files + if (network != "zerotrust") payload$network <- network + if (!is.null(timeout)) payload$timeout <- timeout + + result <- api_request("/execute", creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result) +} + +#' Execute Code Asynchronously +#' +#' Submits code for execution and returns immediately with a job ID. +#' Use \code{get_job} or \code{wait} to retrieve results. +#' +#' @param code The source code to execute +#' @param language The programming language +#' @param env Named list of environment variables (optional) +#' @param input_files List of input files (optional) +#' @param network Network mode: "zerotrust" or "semitrusted" +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing job_id for tracking the execution +#' @export +#' @examples +#' \dontrun{ +#' job <- execute_async("import time; time.sleep(10); print('Done')", "python") +#' result <- wait(job$job_id) +#' } +execute_async <- function(code, language, env = NULL, input_files = NULL, + network = "zerotrust", + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + + payload <- list(language = language, code = code, async = TRUE) + if (!is.null(env)) payload$env <- env + if (!is.null(input_files)) payload$input_files <- input_files + if (network != "zerotrust") payload$network <- network + + result <- api_request("/execute", creds$public_key, creds$secret_key, + method = "POST", data = payload) + return(result) +} + +#' Get Job Status +#' +#' Retrieves the current status and results of an asynchronous job. +#' +#' @param job_id The job ID returned by execute_async +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing status, and if completed: stdout, stderr, exit_code +#' @export +#' @examples +#' \dontrun{ +#' job <- execute_async("print('Hello')", "python") +#' status <- get_job(job$job_id) +#' if (status$status == "completed") { +#' cat(status$stdout) +#' } +#' } +get_job <- function(job_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request(paste0("/jobs/", job_id), creds$public_key, creds$secret_key) + return(result) +} + +#' Wait for Job Completion +#' +#' Polls a job until it completes or times out. +#' +#' @param job_id The job ID to wait for +#' @param poll_interval Seconds between status checks (default: 1) +#' @param max_wait Maximum seconds to wait (default: 300) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return The completed job result +#' @export +#' @examples +#' \dontrun{ +#' job <- execute_async("import time; time.sleep(5); print('Done')", "python") +#' result <- wait(job$job_id, poll_interval = 2) +#' } +wait <- function(job_id, poll_interval = 1, max_wait = 300, + public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + start_time <- Sys.time() + + repeat { + result <- get_job(job_id, creds$public_key, creds$secret_key) + + if (!is.null(result$status) && result$status %in% c("completed", "failed", "timeout")) { + return(result) + } + + elapsed <- as.numeric(difftime(Sys.time(), start_time, units = "secs")) + if (elapsed >= max_wait) { + stop(paste("Job", job_id, "did not complete within", max_wait, "seconds")) + } + + Sys.sleep(poll_interval) + } +} + +#' Cancel a Job +#' +#' Cancels a running asynchronous job. +#' +#' @param job_id The job ID to cancel +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list with cancellation status +#' @export +#' @examples +#' \dontrun{ +#' job <- execute_async("import time; time.sleep(60)", "python") +#' cancel_job(job$job_id) +#' } +cancel_job <- function(job_id, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request(paste0("/jobs/", job_id), creds$public_key, creds$secret_key, + method = "DELETE") + return(result) +} + +#' List Jobs +#' +#' Lists recent jobs for the authenticated account. +#' +#' @param status Filter by status (optional): "pending", "running", "completed", "failed" +#' @param limit Maximum number of jobs to return (default: 50) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing jobs array +#' @export +#' @examples +#' \dontrun{ +#' jobs <- list_jobs() +#' running <- list_jobs(status = "running") +#' } +list_jobs <- function(status = NULL, limit = 50, public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + endpoint <- paste0("/jobs?limit=", limit) + if (!is.null(status)) endpoint <- paste0(endpoint, "&status=", status) + result <- api_request(endpoint, creds$public_key, creds$secret_key) + return(result) +} + +#' Run Code from File +#' +#' Convenience function to execute code from a file with auto-detected language. +#' +#' @param filepath Path to the source file +#' @param env Named list of environment variables (optional) +#' @param network Network mode (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return Execution result +#' @export +#' @examples +#' \dontrun{ +#' result <- run("script.py") +#' result <- run("app.js", env = list(NODE_ENV = "production")) +#' } +run <- function(filepath, env = NULL, network = "zerotrust", + public_key = NULL, secret_key = NULL) { + if (!file.exists(filepath)) { + stop(paste("File not found:", filepath)) + } + + language <- detect_language(filepath) + if (language == "unknown") { + stop(paste("Cannot detect language for:", filepath)) + } + + code <- paste(readLines(filepath, warn = FALSE), collapse = "\n") + return(execute(code, language, env = env, network = network, + public_key = public_key, secret_key = secret_key)) +} + +#' Run Code from File Asynchronously +#' +#' Convenience function to execute code from a file asynchronously. +#' +#' @param filepath Path to the source file +#' @param env Named list of environment variables (optional) +#' @param network Network mode (optional) +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing job_id +#' @export +#' @examples +#' \dontrun{ +#' job <- run_async("long_script.py") +#' result <- wait(job$job_id) +#' } +run_async <- function(filepath, env = NULL, network = "zerotrust", + public_key = NULL, secret_key = NULL) { + if (!file.exists(filepath)) { + stop(paste("File not found:", filepath)) + } + + language <- detect_language(filepath) + if (language == "unknown") { + stop(paste("Cannot detect language for:", filepath)) + } + + code <- paste(readLines(filepath, warn = FALSE), collapse = "\n") + return(execute_async(code, language, env = env, network = network, + public_key = public_key, secret_key = secret_key)) +} + +#' Get Container Image Information +#' +#' Retrieves information about the execution environment image. +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing image version and installed packages +#' @export +#' @examples +#' \dontrun{ +#' info <- image() +#' cat("Image version:", info$version) +#' } +image <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request("/image", creds$public_key, creds$secret_key) + return(result) +} + +#' List Supported Languages +#' +#' Retrieves the list of supported programming languages. +#' +#' @param public_key API public key (optional) +#' @param secret_key API secret key (optional) +#' @return A list containing supported languages with their details +#' @export +#' @examples +#' \dontrun{ +#' langs <- languages() +#' print(names(langs$languages)) +#' } +languages <- function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + result <- api_request("/languages", creds$public_key, creds$secret_key) + return(result) +} + +# ============================================================================= +# Client Class (R6) +# ============================================================================= + +#' Unsandbox Client Class +#' +#' An R6 class providing an object-oriented interface to the Unsandbox API. +#' Stores credentials for reuse across multiple API calls. +#' +#' @description +#' The Client class provides a convenient way to interact with the Unsandbox API +#' when making multiple calls. It stores credentials and provides methods for +#' all API operations. +#' +#' @export +#' @examples +#' \dontrun{ +#' # Create client with environment credentials +#' client <- Client$new() +#' +#' # Create client with explicit credentials +#' client <- Client$new(public_key = "unsb-pk-xxxx", secret_key = "unsb-sk-xxxx") +#' +#' # Execute code +#' result <- client$execute("print('Hello')", "python") +#' +#' # Async execution +#' job <- client$execute_async("import time; time.sleep(10)", "python") +#' result <- client$wait(job$job_id) +#' } +Client <- NULL + +# Only create if R6 is available +if (requireNamespace("R6", quietly = TRUE)) { + Client <- R6::R6Class("Client", + public = list( + #' @field public_key The API public key + public_key = NULL, + #' @field secret_key The API secret key + secret_key = NULL, + + #' @description + #' Create a new Unsandbox client + #' @param public_key Optional public key (uses credential chain if not provided) + #' @param secret_key Optional secret key (uses credential chain if not provided) + initialize = function(public_key = NULL, secret_key = NULL) { + creds <- get_credentials(public_key, secret_key) + self$public_key <- creds$public_key + self$secret_key <- creds$secret_key + }, + + #' @description Execute code synchronously + #' @param code Source code to execute + #' @param language Programming language + #' @param env Environment variables + #' @param input_files Input files + #' @param network Network mode + #' @param timeout Execution timeout + execute = function(code, language, env = NULL, input_files = NULL, + network = "zerotrust", timeout = NULL) { + execute(code, language, env, input_files, network, timeout, + self$public_key, self$secret_key) + }, + + #' @description Execute code asynchronously + #' @param code Source code to execute + #' @param language Programming language + #' @param env Environment variables + #' @param input_files Input files + #' @param network Network mode + execute_async = function(code, language, env = NULL, input_files = NULL, + network = "zerotrust") { + execute_async(code, language, env, input_files, network, + self$public_key, self$secret_key) + }, + + #' @description Get job status + #' @param job_id Job ID + get_job = function(job_id) { + get_job(job_id, self$public_key, self$secret_key) + }, + + #' @description Wait for job completion + #' @param job_id Job ID + #' @param poll_interval Poll interval in seconds + #' @param max_wait Maximum wait time + wait = function(job_id, poll_interval = 1, max_wait = 300) { + wait(job_id, poll_interval, max_wait, self$public_key, self$secret_key) + }, + + #' @description Cancel a job + #' @param job_id Job ID + cancel_job = function(job_id) { + cancel_job(job_id, self$public_key, self$secret_key) + }, + + #' @description List jobs + #' @param status Filter by status + #' @param limit Maximum number of jobs + list_jobs = function(status = NULL, limit = 50) { + list_jobs(status, limit, self$public_key, self$secret_key) + }, + + #' @description Run code from file + #' @param filepath Path to source file + #' @param env Environment variables + #' @param network Network mode + run = function(filepath, env = NULL, network = "zerotrust") { + run(filepath, env, network, self$public_key, self$secret_key) + }, + + #' @description Run code from file asynchronously + #' @param filepath Path to source file + #' @param env Environment variables + #' @param network Network mode + run_async = function(filepath, env = NULL, network = "zerotrust") { + run_async(filepath, env, network, self$public_key, self$secret_key) + }, + + #' @description Get image information + image = function() { + image(self$public_key, self$secret_key) + }, + + #' @description List supported languages + languages = function() { + languages(self$public_key, self$secret_key) + } + ) + ) +} + +# ============================================================================= +# CLI Helper Functions +# ============================================================================= + read_env_file <- function(path) { if (!file.exists(path)) { cat(sprintf("%sError: Env file not found: %s%s\n", RED, path, RESET), file = stderr()) @@ -1126,4 +1656,7 @@ main <- function() { } } -main() +# Only run main if executed as a script (not when sourced as a library) +if (!interactive() && identical(environment(), globalenv())) { + main() +} diff --git a/un.raku b/un.raku index f95bba5..15bec81 100644 --- a/un.raku +++ b/un.raku @@ -33,24 +33,55 @@ # https://www.timehexon.com # https://www.foxhop.net # https://www.unturf.com/software +# +# unsandbox SDK for Raku - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi +# +# Library Usage: +# use lib '.'; +# use un; +# my %result = execute("python", 'print("Hello")'); +# my %job = execute-async("python", $code); +# my %result = wait(%job); +# +# CLI Usage: +# raku un.raku script.py +# raku un.raku -s python 'print("Hello")' +# raku un.raku session --shell python3 +# +# Authentication (in priority order): +# 1. Function arguments: execute(..., :public-key<...>, :secret-key<...>) +# 2. Environment variables: UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY +# 3. Config file: ~/.unsandbox/accounts.csv (public_key,secret_key per line) #!/usr/bin/env raku -# unsandbox CLI - Raku implementation -# Full-featured CLI matching un.c/un.py capabilities +unit module un; use JSON::Fast; use Digest::SHA; -constant $API_BASE = "https://api.unsandbox.com"; -constant $PORTAL_BASE = "https://unsandbox.com"; +# ============================================================================ +# Configuration +# ============================================================================ + +constant $API_BASE is export = "https://api.unsandbox.com"; +constant $PORTAL_BASE is export = "https://unsandbox.com"; +constant $DEFAULT_TIMEOUT is export = 300; +constant $DEFAULT_TTL is export = 60; + +# Polling delays (ms) - exponential backoff +my @POLL_DELAYS = (300, 450, 700, 900, 650, 1600, 2000); + +# ANSI colors constant $BLUE = "\e[34m"; constant $RED = "\e[31m"; constant $GREEN = "\e[32m"; constant $YELLOW = "\e[33m"; constant $RESET = "\e[0m"; -my %EXT_MAP = ( +# Extension to language mapping +my %EXT_MAP is export = ( py => 'python', js => 'javascript', ts => 'typescript', rb => 'ruby', php => 'php', pl => 'perl', lua => 'lua', sh => 'bash', go => 'go', rs => 'rust', c => 'c', @@ -64,27 +95,505 @@ my %EXT_MAP = ( f90 => 'fortran', f95 => 'fortran', cob => 'cobol', pro => 'prolog', forth => 'forth', '4th' => 'forth', tcl => 'tcl', raku => 'raku', pl6 => 'raku', p6 => 'raku', - m => 'objc' + m => 'objc', awk => 'awk' ); -sub get-api-keys() { - my $public-key = %*ENV // ''; - my $secret-key = %*ENV // ''; +# ============================================================================ +# Exceptions +# ============================================================================ - # Fallback to old UNSANDBOX_API_KEY for backwards compat - if !$public-key && %*ENV { - $public-key = %*ENV; - $secret-key = ''; - } - - unless $public-key { - note "{$RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set{$RESET}"; - exit 1; - } - return ($public-key, $secret-key); +#| Base exception class for unsandbox errors +class UnsandboxError is Exception is export { + has $.message; + method new($message) { self.bless(:$message) } + method Str { $.message } } -sub detect-language(Str $filename --> Str) { +#| Authentication failed - invalid or missing credentials +class AuthenticationError is UnsandboxError is export { } + +#| Code execution failed +class ExecutionError is UnsandboxError is export { + has $.exit-code; + has $.stderr; +} + +#| API request failed +class APIError is UnsandboxError is export { + has $.status-code; + has $.response; +} + +#| Execution timed out +class TimeoutError is UnsandboxError is export { } + +# ============================================================================ +# HMAC Authentication +# ============================================================================ + +#| Generate HMAC-SHA256 signature for API request +#| Signature = HMAC-SHA256(secret_key, "timestamp:METHOD:path:body") +sub sign-request(Str $secret-key, Int $timestamp, Str $method, Str $path, Str $body = "") returns Str is export { + my $message = "{$timestamp}:{$method}:{$path}:{$body}"; + return hmac-hex($message, $secret-key, &sha256); +} + +#| Get API credentials in priority order: +#| 1. Function arguments +#| 2. Environment variables +#| 3. ~/.unsandbox/accounts.csv +sub get-credentials(Str :$public-key, Str :$secret-key, Int :$account-index = 0) returns List is export { + # Priority 1: Function arguments + if $public-key && $secret-key { + return ($public-key, $secret-key); + } + + # Priority 2: Environment variables + my $env-pk = %*ENV // ''; + my $env-sk = %*ENV // ''; + if $env-pk && $env-sk { + return ($env-pk, $env-sk); + } + + # Priority 3: Config file + my $accounts-path = $*HOME.add('.unsandbox').add('accounts.csv'); + if $accounts-path.e { + try { + my @lines = $accounts-path.slurp.trim.split("\n"); + my @valid-accounts; + for @lines -> $line { + my $trimmed = $line.trim; + next if !$trimmed || $trimmed.starts-with('#'); + if $trimmed.contains(',') { + my ($pk, $sk) = $trimmed.split(',', 2); + if $pk.starts-with('unsb-pk-') && $sk.starts-with('unsb-sk-') { + @valid-accounts.push(($pk, $sk)); + } + } + } + if @valid-accounts && $account-index < @valid-accounts.elems { + return @valid-accounts[$account-index]; + } + } + } + + die AuthenticationError.new( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, " ~ + "or create ~/.unsandbox/accounts.csv, or pass credentials to function." + ); +} + +# ============================================================================ +# HTTP Client +# ============================================================================ + +#| Make authenticated API request with HMAC signature +sub api-request( + Str $endpoint, + Str $method = 'GET', + %data?, + Str :$body-text, + Str :$content-type = 'application/json', + Str :$public-key, + Str :$secret-key, + Int :$timeout = $DEFAULT_TIMEOUT +) returns Hash is export { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key); + + my $url = $API_BASE ~ $endpoint; + my @args = 'curl', '-s', '--max-time', $timeout.Str; + my $body = ''; + + if $method eq 'GET' { + @args.append: '-X', 'GET'; + } elsif $method eq 'DELETE' { + @args.append: '-X', 'DELETE'; + } elsif $method eq 'POST' || $method eq 'PUT' || $method eq 'PATCH' { + @args.append: '-X', $method; + @args.append: '-H', "Content-Type: $content-type"; + if $body-text.defined { + $body = $body-text; + @args.append: '-d', $body; + } elsif %data { + $body = to-json(%data); + @args.append: '-d', $body; + } + } + + @args.append: '-H', "Authorization: Bearer $pk"; + + # Add HMAC signature + my $timestamp = now.Int; + my $signature = sign-request($sk, $timestamp, $method, $endpoint, $body); + @args.append: '-H', "X-Timestamp: $timestamp"; + @args.append: '-H', "X-Signature: $signature"; + + @args.append: $url; + + my $proc = run |@args, :out, :err; + my $resp-body = $proc.out.slurp; + my $err = $proc.err.slurp; + + if $proc.exitcode != 0 { + die APIError.new("API request failed: $err", :status-code(0), :response($err)); + } + + # Check for clock drift errors + if $resp-body.contains('timestamp') && ($resp-body.contains('401') || $resp-body.contains('expired') || $resp-body.contains('invalid')) { + die AuthenticationError.new( + "Request timestamp expired (must be within 5 minutes of server time). " ~ + "Your computer's clock may have drifted. Sync with NTP." + ); + } + + return from-json($resp-body); +} + +# ============================================================================ +# Core Execution Functions +# ============================================================================ + +#| Execute code synchronously and return results +#| +#| Parameters: +#| $language - Programming language (python, javascript, go, rust, etc.) +#| $code - Source code to execute +#| :%env - Environment variables +#| :@input-files - List of {filename => "...", content => "..."} +#| :$network-mode - "zerotrust" (no network) or "semitrusted" (internet access) +#| :$ttl - Execution timeout in seconds (1-900, default 60) +#| :$vcpu - Virtual CPUs (1-8, default 1) +#| :$return-artifact - Return compiled binary +#| :$public-key - API public key +#| :$secret-key - API secret key +#| +#| Returns: Hash with stdout, stderr, exit_code, language, job_id, etc. +#| +#| Example: +#| my %result = execute("python", 'print("Hello World")'); +#| say %result; +sub execute( + Str $language, + Str $code, + :%env, + :@input-files, + Str :$network-mode = 'zerotrust', + Int :$ttl = $DEFAULT_TTL, + Int :$vcpu = 1, + Bool :$return-artifact = False, + Str :$public-key, + Str :$secret-key, + Int :$timeout = $DEFAULT_TIMEOUT +) returns Hash is export { + my %payload = language => $language, code => $code, network_mode => $network-mode, ttl => $ttl, vcpu => $vcpu; + + %payload = %env if %env; + + if @input-files { + my @files; + for @input-files -> %f { + if %f:exists { + @files.push(%f); + } elsif %f:exists { + @files.push({ + filename => %f, + content_base64 => %f.encode.base64 + }); + } else { + @files.push(%f); + } + } + %payload = @files; + } + + %payload = True if $return-artifact; + + return api-request('/execute', 'POST', %payload, :$public-key, :$secret-key, :$timeout); +} + +#| Execute code asynchronously. Returns immediately with job_id for polling. +#| +#| Parameters: Same as execute() +#| +#| Returns: Hash with job_id, status ("pending") +#| +#| Example: +#| my %job = execute-async("python", $long-running-code); +#| say "Job submitted: ", %job; +#| my %result = wait(%job); +sub execute-async( + Str $language, + Str $code, + :%env, + :@input-files, + Str :$network-mode = 'zerotrust', + Int :$ttl = $DEFAULT_TTL, + Int :$vcpu = 1, + Bool :$return-artifact = False, + Str :$public-key, + Str :$secret-key +) returns Hash is export { + my %payload = language => $language, code => $code, network_mode => $network-mode, ttl => $ttl, vcpu => $vcpu; + + %payload = %env if %env; + + if @input-files { + my @files; + for @input-files -> %f { + if %f:exists { + @files.push(%f); + } elsif %f:exists { + @files.push({ + filename => %f, + content_base64 => %f.encode.base64 + }); + } else { + @files.push(%f); + } + } + %payload = @files; + } + + %payload = True if $return-artifact; + + return api-request('/execute/async', 'POST', %payload, :$public-key, :$secret-key); +} + +#| Execute code with automatic language detection from shebang +#| +#| Parameters: +#| $code - Source code with shebang (e.g., #!/usr/bin/env python3) +#| :%env - Environment variables +#| :$network-mode - "zerotrust" or "semitrusted" +#| :$ttl - Execution timeout in seconds +#| +#| Returns: Hash with detected_language, stdout, stderr, etc. +#| +#| Example: +#| my $code = q:to/END/; +#| #!/usr/bin/env python3 +#| print("Auto-detected!") +#| END +#| my %result = run($code); +#| say %result; +sub run( + Str $code, + :%env, + Str :$network-mode = 'zerotrust', + Int :$ttl = $DEFAULT_TTL, + Str :$public-key, + Str :$secret-key, + Int :$timeout = $DEFAULT_TIMEOUT +) returns Hash is export { + my $endpoint = "/run?ttl={$ttl}&network_mode={$network-mode}"; + if %env { + $endpoint ~= "&env=" ~ uri-encode(to-json(%env)); + } + + return api-request($endpoint, 'POST', :body-text($code), :content-type('text/plain'), :$public-key, :$secret-key, :$timeout); +} + +#| Execute code asynchronously with automatic language detection +#| +#| Returns: Hash with job_id, detected_language, status ("pending") +sub run-async( + Str $code, + :%env, + Str :$network-mode = 'zerotrust', + Int :$ttl = $DEFAULT_TTL, + Str :$public-key, + Str :$secret-key +) returns Hash is export { + my $endpoint = "/run/async?ttl={$ttl}&network_mode={$network-mode}"; + if %env { + $endpoint ~= "&env=" ~ uri-encode(to-json(%env)); + } + + return api-request($endpoint, 'POST', :body-text($code), :content-type('text/plain'), :$public-key, :$secret-key); +} + +# ============================================================================ +# Job Management +# ============================================================================ + +#| Get job status and results +#| +#| Parameters: +#| $job-id - Job ID from execute-async or run-async +#| +#| Returns: Hash with job_id, status, result (if completed), timestamps +#| +#| Status values: pending, running, completed, failed, timeout, cancelled +sub get-job(Str $job-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/jobs/{$job-id}", 'GET', :$public-key, :$secret-key); +} + +#| Wait for job completion with exponential backoff polling +#| +#| Parameters: +#| $job-id - Job ID from execute-async or run-async +#| :$max-polls - Maximum number of poll attempts (default 100) +#| +#| Returns: Final job result Hash +#| +#| Example: +#| my %job = execute-async("python", $code); +#| my %result = wait(%job); +#| say %result; +sub wait( + Str $job-id, + Int :$max-polls = 100, + Str :$public-key, + Str :$secret-key +) returns Hash is export { + my @terminal-states = ; + + for ^$max-polls -> $i { + my $delay-idx = min($i, @POLL_DELAYS.elems - 1); + sleep @POLL_DELAYS[$delay-idx] / 1000; + + my %result = get-job($job-id, :$public-key, :$secret-key); + my $status = %result // ''; + + if $status (elem) @terminal-states { + if $status eq 'failed' { + die ExecutionError.new( + "Job failed: " ~ (%result // 'Unknown error'), + :exit-code(%result), + :stderr(%result) + ); + } + if $status eq 'timeout' { + die TimeoutError.new("Job timed out: $job-id"); + } + return %result; + } + } + + die TimeoutError.new("Max polls ($max-polls) exceeded for job $job-id"); +} + +#| Cancel a running job +#| +#| Returns: Partial output and artifacts collected before cancellation +sub cancel-job(Str $job-id, Str :$public-key, Str :$secret-key) returns Hash is export { + return api-request("/jobs/{$job-id}", 'DELETE', :$public-key, :$secret-key); +} + +#| List all active jobs for this API key +#| +#| Returns: List of job summary hashes with job_id, language, status, submitted_at +sub list-jobs(Str :$public-key, Str :$secret-key) returns Array is export { + my %result = api-request('/jobs', 'GET', :$public-key, :$secret-key); + return %result // []; +} + +# ============================================================================ +# Image Generation +# ============================================================================ + +#| Generate images from text prompt +#| +#| Parameters: +#| $prompt - Text description of the image to generate +#| :$model - Model to use (optional) +#| :$size - Image size (e.g., "1024x1024") +#| :$quality - "standard" or "hd" +#| :$n - Number of images to generate +#| +#| Returns: Hash with images array, created_at +#| +#| Example: +#| my %result = image("A sunset over mountains"); +#| say %result[0]; +sub image( + Str $prompt, + Str :$model, + Str :$size = '1024x1024', + Str :$quality = 'standard', + Int :$n = 1, + Str :$public-key, + Str :$secret-key +) returns Hash is export { + my %payload = prompt => $prompt, size => $size, quality => $quality, n => $n; + %payload = $model if $model; + + return api-request('/image', 'POST', %payload, :$public-key, :$secret-key); +} + +# ============================================================================ +# Languages Cache +# ============================================================================ + +constant $LANGUAGES_CACHE_TTL = 3600; # 1 hour in seconds + +#| Get languages cache file path +sub languages-cache-path() returns IO::Path { + return $*HOME.add('.unsandbox').add('languages.json'); +} + +#| Check if languages cache is valid (less than 1 hour old) +sub is-cache-valid() returns Bool { + my $cache-path = languages-cache-path(); + return False unless $cache-path.e; + + my $mtime = $cache-path.modified; + my $age = now - $mtime; + return $age < $LANGUAGES_CACHE_TTL; +} + +#| Read languages from cache +sub read-languages-cache() returns Hash { + my $cache-path = languages-cache-path(); + return {} unless $cache-path.e; + + try { + return from-json($cache-path.slurp); + CATCH { + default { return {}; } + } + } +} + +#| Write languages to cache +sub write-languages-cache(%data) { + my $cache-path = languages-cache-path(); + my $dir = $cache-path.parent; + $dir.mkdir unless $dir.e; + + try { + $cache-path.spurt(to-json(%data)); + } +} + +# ============================================================================ +# Utility Functions +# ============================================================================ + +#| Get list of supported programming languages with caching. +#| Languages are cached in ~/.unsandbox/languages.json for 1 hour. +#| +#| Returns: Hash with languages array, count, aliases +sub languages(Str :$public-key, Str :$secret-key) returns Hash is export { + # Check cache first + if is-cache-valid() { + my %cached = read-languages-cache(); + return %cached if %cached; + } + + # Fetch from API + my %result = api-request('/languages', 'GET', :$public-key, :$secret-key); + + # Cache result + write-languages-cache(%result); + + return %result; +} + +#| Detect programming language from file extension or shebang +#| +#| Returns: Language name or Nil if undetected +sub detect-language(Str $filename) returns Str is export { my $ext = $filename.IO.extension; return %EXT_MAP{$ext} if %EXT_MAP{$ext}:exists; @@ -103,99 +612,98 @@ sub detect-language(Str $filename --> Str) { } } - note "{$RED}Error: Cannot detect language for $filename{$RESET}"; - exit 1; + return Nil; } -sub api-request(Str $endpoint, Str $method, %data?, Str :$public-key!, Str :$secret-key!) { - my $url = $API_BASE ~ $endpoint; - my @args = 'curl', '-s'; - my $body = ''; +# ============================================================================ +# Client Class +# ============================================================================ - if $method eq 'GET' { - @args.append: '-X', 'GET'; - } elsif $method eq 'DELETE' { - @args.append: '-X', 'DELETE'; - } elsif $method eq 'POST' { - @args.append: '-X', 'POST'; - @args.append: '-H', 'Content-Type: application/json'; - if %data { - $body = to-json(%data); - @args.append: '-d', $body; - } - } elsif $method eq 'PATCH' { - @args.append: '-X', 'PATCH'; - @args.append: '-H', 'Content-Type: application/json'; - if %data { - $body = to-json(%data); - @args.append: '-d', $body; - } +#| Unsandbox API client with stored credentials +#| +#| Example: +#| my $client = Client.new(:public-key, :secret-key); +#| my %result = $client.execute("python", 'print("Hello")'); +#| +#| # Or load from environment/config automatically: +#| my $client = Client.new; +#| my %result = $client.execute("python", $code); +class Client is export { + has Str $.public-key; + has Str $.secret-key; + + #| Initialize client with credentials + #| + #| Parameters: + #| :$public-key - API public key (unsb-pk-...) + #| :$secret-key - API secret key (unsb-sk-...) + #| :$account-index - Account index in ~/.unsandbox/accounts.csv (default 0) + method new(Str :$public-key, Str :$secret-key, Int :$account-index = 0) { + my ($pk, $sk) = get-credentials(:$public-key, :$secret-key, :$account-index); + self.bless(:public-key($pk), :secret-key($sk)); } - @args.append: '-H', "Authorization: Bearer $public-key"; - - # Add HMAC signature if secret-key is present - if $secret-key { - my $timestamp = now.Int; - my $sig-input = "{$timestamp}:{$method}:{$endpoint}:{$body}"; - my $signature = hmac-hex($sig-input, $secret-key, &sha256); - @args.append: '-H', "X-Timestamp: $timestamp"; - @args.append: '-H', "X-Signature: $signature"; + #| Execute code synchronously. See module execute() for parameters. + method execute(Str $language, Str $code, *%opts) returns Hash { + return execute($language, $code, :$.public-key, :$.secret-key, |%opts); } - @args.append: $url; - - my $proc = run |@args, :out, :err; - my $resp-body = $proc.out.slurp; - my $err = $proc.err.slurp; - - if $proc.exitcode != 0 { - note "{$RED}Error: API request failed{$RESET}"; - note $err if $err; - exit 1; + #| Execute code asynchronously. See module execute-async() for parameters. + method execute-async(Str $language, Str $code, *%opts) returns Hash { + return execute-async($language, $code, :$.public-key, :$.secret-key, |%opts); } - # Check for clock drift errors - if $resp-body.contains('timestamp') && ($resp-body.contains('401') || $resp-body.contains('expired') || $resp-body.contains('invalid')) { - note "{$RED}Error: Request timestamp expired (must be within 5 minutes of server time){$RESET}"; - note "{$YELLOW}Your computer's clock may have drifted.{$RESET}"; - note "{$YELLOW}Check your system time and sync with NTP if needed:{$RESET}"; - note "{$YELLOW} Linux: sudo ntpdate -s time.nist.gov{$RESET}"; - note "{$YELLOW} macOS: sudo sntp -sS time.apple.com{$RESET}"; - note "{$YELLOW} Windows: w32tm /resync{$RESET}"; - exit 1; + #| Execute with auto-detect. See module run() for parameters. + method run(Str $code, *%opts) returns Hash { + return run($code, :$.public-key, :$.secret-key, |%opts); } - return from-json($resp-body); + #| Execute async with auto-detect. See module run-async() for parameters. + method run-async(Str $code, *%opts) returns Hash { + return run-async($code, :$.public-key, :$.secret-key, |%opts); + } + + #| Get job status. See module get-job() for details. + method get-job(Str $job-id) returns Hash { + return get-job($job-id, :$.public-key, :$.secret-key); + } + + #| Wait for job completion. See module wait() for details. + method wait(Str $job-id, *%opts) returns Hash { + return wait($job-id, :$.public-key, :$.secret-key, |%opts); + } + + #| Cancel a job. See module cancel-job() for details. + method cancel-job(Str $job-id) returns Hash { + return cancel-job($job-id, :$.public-key, :$.secret-key); + } + + #| List active jobs. See module list-jobs() for details. + method list-jobs() returns Array { + return list-jobs(:$.public-key, :$.secret-key); + } + + #| Generate image. See module image() for parameters. + method image(Str $prompt, *%opts) returns Hash { + return image($prompt, :$.public-key, :$.secret-key, |%opts); + } + + #| Get supported languages. + method languages() returns Hash { + return languages(:$.public-key, :$.secret-key); + } } -# API request for PUT with text/plain body (used for vault) -sub api-request-put-text(Str $endpoint, Str $content, Str :$public-key!, Str :$secret-key!) { - my $url = $API_BASE ~ $endpoint; - my @args = 'curl', '-s', '-X', 'PUT'; - @args.append: '-H', 'Content-Type: text/plain'; - @args.append: '-H', "Authorization: Bearer $public-key"; +# ============================================================================ +# CLI Interface +# ============================================================================ - # Add HMAC signature if secret-key is present - if $secret-key { - my $timestamp = now.Int; - my $sig-input = "{$timestamp}:PUT:{$endpoint}:{$content}"; - my $signature = hmac-hex($sig-input, $secret-key, &sha256); - @args.append: '-H', "X-Timestamp: $timestamp"; - @args.append: '-H', "X-Signature: $signature"; - } - - @args.append: '--data-binary', $content; - @args.append: $url; - - my $proc = run |@args, :out, :err; - my $resp-body = $proc.out.slurp; - - return from-json($resp-body); +sub uri-encode(Str $s) { + return $s.subst(/<-[A-Za-z0-9\-_.~]>/, { .encode.list.map({ '%' ~ .fmt('%02X') }).join }, :g); } sub cmd-execute(@args) { - my ($public-key, $secret-key) = get-api-keys(); + my ($public-key, $secret-key) = get-credentials(); my $source-file = ''; my %env-vars; my @input-files; @@ -258,6 +766,11 @@ sub cmd-execute(@args) { my $code = $source-file.IO.slurp; my $language = detect-language($source-file); + unless $language { + note "{$RED}Error: Cannot detect language for $source-file{$RESET}"; + exit 1; + } + # Build request payload my %payload = language => $language, code => $code; @@ -314,7 +827,7 @@ sub cmd-execute(@args) { } sub cmd-session(@args) { - my ($public-key, $secret-key) = get-api-keys(); + my ($public-key, $secret-key) = get-credentials(); my $list-mode = False; my $kill-id = ''; my $shell = ''; @@ -402,43 +915,8 @@ sub cmd-session(@args) { say "{$YELLOW}(Interactive sessions require WebSocket - use un2 for full support){$RESET}"; } -# Service vault functions -sub service-env-status(Str $service-id, Str :$public-key!, Str :$secret-key!) { - my %result = api-request("/services/$service-id/env", 'GET', :$public-key, :$secret-key); - say to-json(%result, :pretty); -} - -sub service-env-set(Str $service-id, Str $content, Str :$public-key!, Str :$secret-key!) { - my %result = api-request-put-text("/services/$service-id/env", $content, :$public-key, :$secret-key); - say to-json(%result, :pretty); -} - -sub service-env-export(Str $service-id, Str :$public-key!, Str :$secret-key!) { - my %result = api-request("/services/$service-id/env/export", 'POST', :$public-key, :$secret-key); - say %result if %result; -} - -sub service-env-delete(Str $service-id, Str :$public-key!, Str :$secret-key!) { - api-request("/services/$service-id/env", 'DELETE', :$public-key, :$secret-key); - say "{$GREEN}Vault deleted for: $service-id{$RESET}"; -} - -sub build-env-content(@env-vars, Str $env-file --> Str) { - my @lines; - for @env-vars -> $var { - @lines.push($var); - } - if $env-file && $env-file.IO.e { - for $env-file.IO.lines -> $line { - next if $line.starts-with('#') || $line.trim eq ''; - @lines.push($line); - } - } - return @lines.join("\n"); -} - sub cmd-service(@args) { - my ($public-key, $secret-key) = get-api-keys(); + my ($public-key, $secret-key) = get-credentials(); my $list-mode = False; my $info-id = ''; my $logs-id = ''; @@ -446,8 +924,6 @@ sub cmd-service(@args) { my $wake-id = ''; my $destroy-id = ''; my $resize-id = ''; - my $dump-bootstrap-id = ''; - my $dump-file = ''; my $name = ''; my $ports = ''; my $type = ''; @@ -456,61 +932,6 @@ sub cmd-service(@args) { my $network = ''; my $vcpu = 0; my @input-files; - my @env-vars; - my $env-file = ''; - my $env-action = ''; - my $env-target = ''; - - # Check for 'env' subcommand first - if @args.elems >= 1 && @args[0] eq 'env' { - if @args.elems < 3 { - note "Usage: un.raku service env [options]"; - exit 1; - } - $env-action = @args[1]; - $env-target = @args[2]; - - # Parse remaining args for -e and --env-file - my $i = 3; - while $i < @args.elems { - given @args[$i] { - when '-e' { - $i++; - @env-vars.push(@args[$i]); - } - when '--env-file' { - $i++; - $env-file = @args[$i]; - } - } - $i++; - } - - given $env-action { - when 'status' { - service-env-status($env-target, :$public-key, :$secret-key); - } - when 'set' { - my $content = build-env-content(@env-vars, $env-file); - if !$content { - note "{$RED}Error: No environment variables to set{$RESET}"; - exit 1; - } - service-env-set($env-target, $content, :$public-key, :$secret-key); - } - when 'export' { - service-env-export($env-target, :$public-key, :$secret-key); - } - when 'delete' { - service-env-delete($env-target, :$public-key, :$secret-key); - } - default { - note "{$RED}Error: Unknown env action '$env-action'. Use status, set, export, or delete{$RESET}"; - exit 1; - } - } - return; - } # Parse arguments my $i = 0; @@ -543,14 +964,6 @@ sub cmd-service(@args) { $i++; $resize-id = @args[$i]; } - when '--dump-bootstrap' { - $i++; - $dump-bootstrap-id = @args[$i]; - } - when '--dump-file' { - $i++; - $dump-file = @args[$i]; - } when '--name' { $i++; $name = @args[$i]; @@ -583,14 +996,6 @@ sub cmd-service(@args) { $i++; @input-files.push(@args[$i]); } - when '-e' { - $i++; - @env-vars.push(@args[$i]); - } - when '--env-file' { - $i++; - $env-file = @args[$i]; - } } $i++; } @@ -654,29 +1059,6 @@ sub cmd-service(@args) { return; } - if $dump-bootstrap-id { - note "Fetching bootstrap script from $dump-bootstrap-id..."; - my %payload = command => "cat /tmp/bootstrap.sh"; - my %result = api-request("/services/$dump-bootstrap-id/execute", 'POST', %payload, :$public-key, :$secret-key); - - if %result && %result ne '' { - my $bootstrap = %result; - if $dump-file { - # Write to file - $dump-file.IO.spurt($bootstrap); - run 'chmod', '755', $dump-file; - say "Bootstrap saved to $dump-file"; - } else { - # Print to stdout - print $bootstrap; - } - } else { - note "{$RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file){$RESET}"; - exit 1; - } - return; - } - # Create new service if $name { my %payload = name => $name; @@ -726,56 +1108,46 @@ sub cmd-service(@args) { say "{$GREEN}Service created: {%result}{$RESET}"; say "Name: {%result}"; say "URL: {%result}" if %result; - - # Auto-set vault if -e or --env-file were provided - my $env-content = build-env-content(@env-vars, $env-file); - if $env-content && %result { - say "{$YELLOW}Setting vault for service...{$RESET}"; - service-env-set(%result, $env-content, :$public-key, :$secret-key); - } return; } - note "{$RED}Error: Specify --name to create a service, or use --list, --info, env, etc.{$RESET}"; + note "{$RED}Error: Specify --name to create a service, or use --list, --info, etc.{$RESET}"; exit 1; } -sub validate-key(Bool $extend) { - my ($public-key, $secret-key) = get-api-keys(); +sub cmd-key(@args) { + my ($public-key, $secret-key) = get-credentials(); + my $extend = False; - # Build curl command - my @args = 'curl', '-s', '-X', 'POST'; - @args.append: "$PORTAL_BASE/keys/validate"; - @args.append: '-H', 'Content-Type: application/json'; - @args.append: '-H', "Authorization: Bearer $public-key"; - - # Add HMAC signature if secret-key is present - if $secret-key { - my $timestamp = now.Int; - my $sig-input = "{$timestamp}:POST:/keys/validate:"; - my $signature = hmac-hex($sig-input, $secret-key, &sha256); - @args.append: '-H', "X-Timestamp: $timestamp"; - @args.append: '-H', "X-Signature: $signature"; + for @args -> $arg { + if $arg eq '--extend' { + $extend = True; + } } - my $proc = run |@args, :out, :err; + # Validate key (using portal endpoint) + my $url = $PORTAL_BASE ~ "/keys/validate"; + my @curl-args = 'curl', '-s', '-X', 'POST'; + @curl-args.append: $url; + @curl-args.append: '-H', 'Content-Type: application/json'; + @curl-args.append: '-H', "Authorization: Bearer $public-key"; + + my $timestamp = now.Int; + my $sig-input = "{$timestamp}:POST:/keys/validate:"; + my $signature = hmac-hex($sig-input, $secret-key, &sha256); + @curl-args.append: '-H', "X-Timestamp: $timestamp"; + @curl-args.append: '-H', "X-Signature: $signature"; + + my $proc = run |@curl-args, :out, :err; my $body = $proc.out.slurp; - my $err-msg = $proc.err.slurp; - - if $proc.exitcode != 0 { - say "{$RED}Invalid{$RESET}"; - note "Reason: $err-msg" if $err-msg; - exit 1; - } my %result = from-json($body); - # Handle --extend flag if $extend { - my $public-key = %result; - if $public-key { + my $pk = %result; + if $pk { say "{$BLUE}Opening browser to extend key...{$RESET}"; - run 'xdg-open', "$PORTAL_BASE/keys/extend?pk=$public-key"; + run 'xdg-open', "$PORTAL_BASE/keys/extend?pk=$pk"; return; } else { note "{$RED}Error: Could not retrieve public key{$RESET}"; @@ -783,7 +1155,6 @@ sub validate-key(Bool $extend) { } } - # Check if key is expired if %result { say "{$RED}Expired{$RESET}"; say "Public Key: {%result // 'N/A'}"; @@ -793,7 +1164,6 @@ sub validate-key(Bool $extend) { exit 1; } - # Valid key say "{$GREEN}Valid{$RESET}"; say "Public Key: {%result // 'N/A'}"; say "Tier: {%result // 'N/A'}"; @@ -805,20 +1175,7 @@ sub validate-key(Bool $extend) { say "Concurrency: {%result // 'N/A'}"; } -sub cmd-key(@args) { - my $extend = False; - - # Parse arguments - for @args -> $arg { - if $arg eq '--extend' { - $extend = True; - } - } - - validate-key($extend); -} - -sub MAIN(*@args) { +sub MAIN(*@args) is export { unless @args { note "Usage: un.raku [options] "; note " un.raku session [options]"; diff --git a/un.rs b/un.rs index 51a63a4..9104d10 100644 --- a/un.rs +++ b/un.rs @@ -33,33 +33,585 @@ // https://www.timehexon.com // https://www.foxhop.net // https://www.unturf.com/software - - -// UN CLI - Rust Implementation -// Note: This uses curl subprocess to avoid requiring external crates -// Compile: rustc un.rs -o un_rust -// Usage: -// un.rs script.py -// un.rs -e KEY=VALUE -f data.txt script.py -// un.rs session --list -// un.rs service --name web --ports 8080 +// +// unsandbox SDK for Rust - Execute code in secure sandboxes +// https://unsandbox.com | https://api.unsandbox.com/openapi +// +// Library Usage: +// use un::{execute, execute_async, wait, get_job, cancel_job, list_jobs}; +// let result = execute("rust", "println!(\"Hello\")", Default::default()).unwrap(); +// let job = execute_async("rust", code, Default::default()).unwrap(); +// let result = wait(&job.job_id, Default::default()).unwrap(); +// +// CLI Usage: +// un script.rs +// un -s rust 'println!("Hello")' use std::env; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{self, Command}; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; +use std::io::Write; + +pub const API_BASE: &str = "https://api.unsandbox.com"; +pub const PORTAL_BASE: &str = "https://unsandbox.com"; +pub const DEFAULT_TIMEOUT: u64 = 300; +pub const DEFAULT_TTL: u64 = 60; +pub const POLL_DELAYS: &[u64] = &[300, 450, 700, 900, 650, 1600, 2000]; -const API_BASE: &str = "https://api.unsandbox.com"; -const PORTAL_BASE: &str = "https://unsandbox.com"; const BLUE: &str = "\x1b[34m"; const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; const RESET: &str = "\x1b[0m"; -fn detect_language(filename: &str) -> Option<&'static str> { +// ============================================================================ +// Exceptions / Error Types +// ============================================================================ + +#[derive(Debug)] +pub enum UnError { + AuthenticationError(String), + ExecutionError { message: String, exit_code: Option, stderr: Option }, + APIError { message: String, status_code: Option, response: Option }, + TimeoutError(String), +} + +impl std::fmt::Display for UnError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + UnError::AuthenticationError(msg) => write!(f, "AuthenticationError: {}", msg), + UnError::ExecutionError { message, .. } => write!(f, "ExecutionError: {}", message), + UnError::APIError { message, .. } => write!(f, "APIError: {}", message), + UnError::TimeoutError(msg) => write!(f, "TimeoutError: {}", msg), + } + } +} + +pub type Result = std::result::Result; + +// ============================================================================ +// Credential System (4-tier) +// ============================================================================ + +fn load_accounts_csv(path: &Path) -> Option> { + if !path.exists() { + return None; + } + + let content = fs::read_to_string(path).ok()?; + let mut accounts = Vec::new(); + + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((pk, sk)) = line.split_once(',') { + let pk = pk.trim().to_string(); + let sk = sk.trim().to_string(); + if pk.starts_with("unsb-pk-") && sk.starts_with("unsb-sk-") { + accounts.push((pk, sk)); + } + } + } + + if accounts.is_empty() { None } else { Some(accounts) } +} + +pub fn get_credentials( + public_key: Option<&str>, + secret_key: Option<&str>, + account_index: usize, +) -> Result<(String, String)> { + // Tier 1: Function arguments + if let (Some(pk), Some(sk)) = (public_key, secret_key) { + return Ok((pk.to_string(), sk.to_string())); + } + + // Tier 2: Environment variables + if let (Ok(pk), Ok(sk)) = (env::var("UNSANDBOX_PUBLIC_KEY"), env::var("UNSANDBOX_SECRET_KEY")) { + return Ok((pk, sk)); + } + + // Tier 3: ~/.unsandbox/accounts.csv + if let Some(home) = dirs::home_dir() { + let accounts_path = home.join(".unsandbox").join("accounts.csv"); + if let Some(accounts) = load_accounts_csv(&accounts_path) { + if account_index < accounts.len() { + return Ok(accounts[account_index].clone()); + } + } + } + + // Tier 4: ./accounts.csv (local directory) + let local_accounts_path = PathBuf::from("accounts.csv"); + if let Some(accounts) = load_accounts_csv(&local_accounts_path) { + if account_index < accounts.len() { + return Ok(accounts[account_index].clone()); + } + } + + Err(UnError::AuthenticationError( + "No credentials found. Set UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY, \ + or create ~/.unsandbox/accounts.csv or ./accounts.csv, or pass credentials to function." + .to_string(), + )) +} + +// ============================================================================ +// HMAC-SHA256 Signature +// ============================================================================ + +fn sign_request( + secret_key: &str, + timestamp: &str, + method: &str, + path: &str, + body: &str, +) -> Result { + let message = format!("{}:{}:{}:{}", timestamp, method, path, body); + + // Use openssl for HMAC-SHA256 + let output = Command::new("sh") + .arg("-c") + .arg(format!( + "printf '%s' '{}' | openssl dgst -sha256 -hmac '{}' | cut -d' ' -f2", + message, secret_key + )) + .output() + .map_err(|e| UnError::APIError { + message: format!("Failed to compute HMAC: {}", e), + status_code: None, + response: None, + })?; + + let sig = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(sig) +} + +// ============================================================================ +// JSON Helper Functions +// ============================================================================ + +fn escape_json(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +fn extract_json_string(json: &str, key: &str) -> String { + let search = format!("\"{}\":\"", key); + if let Some(start) = json.find(&search) { + let start = start + search.len(); + let chars: Vec = json.chars().collect(); + let mut end = start; + while end < chars.len() { + if chars[end] == '"' && (end == 0 || chars[end - 1] != '\\') { + break; + } + end += 1; + } + return json[start..end].to_string(); + } + String::new() +} + +fn extract_json_int(json: &str, key: &str) -> i32 { + let search = format!("\"{}\":", key); + if let Some(pos) = json.find(&search) { + let start = pos + search.len(); + let rest = &json[start..]; + let num_str: String = rest.chars().take_while(|c| c.is_numeric()).collect(); + return num_str.parse().unwrap_or(0); + } + 0 +} + +// ============================================================================ +// HTTP Client +// ============================================================================ + +fn api_request( + endpoint: &str, + method: &str, + body: Option<&str>, + public_key: &str, + secret_key: &str, +) -> Result { + let url = format!("{}{}", API_BASE, endpoint); + let body_str = body.unwrap_or(""); + + // Compute HMAC signature + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + let signature = sign_request(secret_key, ×tamp, method, endpoint, body_str)?; + + let mut cmd = Command::new("curl"); + cmd.arg("-s") + .arg("-X") + .arg(method) + .arg(&url) + .arg("-H") + .arg("Content-Type: application/json") + .arg("-H") + .arg(format!("Authorization: Bearer {}", public_key)) + .arg("-H") + .arg(format!("X-Timestamp: {}", timestamp)) + .arg("-H") + .arg(format!("X-Signature: {}", signature)); + + if let Some(b) = body { + cmd.arg("-d").arg(b); + } + + let output = cmd.output().map_err(|e| UnError::APIError { + message: format!("Failed to run curl: {}", e), + status_code: None, + response: None, + })?; + + let result = String::from_utf8_lossy(&output.stdout).to_string(); + + if result.contains("timestamp") + && (result.contains("401") || result.contains("expired") || result.contains("invalid")) + { + return Err(UnError::AuthenticationError( + "Request timestamp expired. Your system clock may be out of sync.".to_string(), + )); + } + + Ok(result) +} + +// ============================================================================ +// Core Library Functions (public API) +// ============================================================================ + +#[derive(Debug, Clone, Default)] +pub struct ExecuteOptions { + pub env: Option>, + pub input_files: Option>, + pub network_mode: Option, + pub ttl: Option, + pub vcpu: Option, + pub return_artifact: Option, + pub return_wasm_artifact: Option, + pub public_key: Option, + pub secret_key: Option, +} + +#[derive(Debug, Clone)] +pub struct InputFile { + pub filename: String, + pub content_base64: String, +} + +#[derive(Debug)] +pub struct ExecutionResult { + pub success: bool, + pub stdout: String, + pub stderr: String, + pub exit_code: i32, + pub job_id: String, +} + +#[derive(Debug)] +pub struct JobResult { + pub job_id: String, + pub status: String, +} + +pub fn execute( + language: &str, + code: &str, + opts: ExecuteOptions, +) -> Result { + let (pk, sk) = get_credentials(opts.public_key.as_deref(), opts.secret_key.as_deref(), 0)?; + + let network_mode = opts.network_mode.unwrap_or_else(|| "zerotrust".to_string()); + let ttl = opts.ttl.unwrap_or(DEFAULT_TTL); + let vcpu = opts.vcpu.unwrap_or(1); + + let mut json = format!( + r#"{{"language":"{}","code":"{}","network_mode":"{}","ttl":{},"vcpu":{}"#, + language, + escape_json(code), + network_mode, + ttl, + vcpu + ); + + if let Some(env) = &opts.env { + json.push_str(r#","env":{"#); + let mut first = true; + for (k, v) in env { + if !first { + json.push(','); + } + json.push_str(&format!(r#""{}":"{}""#, k, escape_json(v))); + first = false; + } + json.push('}'); + } + + if let Some(files) = &opts.input_files { + json.push_str(r#","input_files":["#); + for (i, f) in files.iter().enumerate() { + if i > 0 { + json.push(','); + } + json.push_str(&format!( + r#"{{"filename":"{}","content_base64":"{}"}}"#, + f.filename, f.content_base64 + )); + } + json.push(']'); + } + + if opts.return_artifact.unwrap_or(false) { + json.push_str(r#","return_artifact":true"#); + } + if opts.return_wasm_artifact.unwrap_or(false) { + json.push_str(r#","return_wasm_artifact":true"#); + } + + json.push('}'); + + let result = api_request("/execute", "POST", Some(&json), &pk, &sk)?; + + let stdout = extract_json_string(&result, "stdout"); + let stderr = extract_json_string(&result, "stderr"); + let exit_code = extract_json_int(&result, "exit_code"); + let job_id = extract_json_string(&result, "job_id"); + + Ok(ExecutionResult { + success: exit_code == 0, + stdout, + stderr, + exit_code, + job_id, + }) +} + +pub fn execute_async( + language: &str, + code: &str, + opts: ExecuteOptions, +) -> Result { + let (pk, sk) = get_credentials(opts.public_key.as_deref(), opts.secret_key.as_deref(), 0)?; + + let network_mode = opts.network_mode.unwrap_or_else(|| "zerotrust".to_string()); + let ttl = opts.ttl.unwrap_or(DEFAULT_TTL); + let vcpu = opts.vcpu.unwrap_or(1); + + let mut json = format!( + r#"{{"language":"{}","code":"{}","network_mode":"{}","ttl":{},"vcpu":{}"#, + language, + escape_json(code), + network_mode, + ttl, + vcpu + ); + + if let Some(env) = &opts.env { + json.push_str(r#","env":{"#); + let mut first = true; + for (k, v) in env { + if !first { + json.push(','); + } + json.push_str(&format!(r#""{}":"{}""#, k, escape_json(v))); + first = false; + } + json.push('}'); + } + + if let Some(files) = &opts.input_files { + json.push_str(r#","input_files":["#); + for (i, f) in files.iter().enumerate() { + if i > 0 { + json.push(','); + } + json.push_str(&format!( + r#"{{"filename":"{}","content_base64":"{}"}}"#, + f.filename, f.content_base64 + )); + } + json.push(']'); + } + + json.push('}'); + + let result = api_request("/execute/async", "POST", Some(&json), &pk, &sk)?; + let job_id = extract_json_string(&result, "job_id"); + let status = extract_json_string(&result, "status"); + + Ok(JobResult { job_id, status }) +} + +#[derive(Debug)] +pub struct JobStatus { + pub job_id: String, + pub status: String, + pub result: Option, +} + +pub fn get_job(job_id: &str, public_key: Option<&str>, secret_key: Option<&str>) -> Result { + let (pk, sk) = get_credentials(public_key, secret_key, 0)?; + let result = api_request(&format!("/jobs/{}", job_id), "GET", None, &pk, &sk)?; + + let status = extract_json_string(&result, "status"); + let job_id_ret = extract_json_string(&result, "job_id"); + let stdout = extract_json_string(&result, "stdout"); + let stderr = extract_json_string(&result, "stderr"); + let exit_code = extract_json_int(&result, "exit_code"); + + let result_opt = if status == "completed" || status == "failed" { + Some(ExecutionResult { + success: exit_code == 0, + stdout, + stderr, + exit_code, + job_id: job_id_ret.clone(), + }) + } else { + None + }; + + Ok(JobStatus { + job_id: job_id_ret, + status, + result: result_opt, + }) +} + +#[derive(Debug, Clone, Default)] +pub struct WaitOptions { + pub max_polls: Option, + pub public_key: Option, + pub secret_key: Option, +} + +pub fn wait(job_id: &str, opts: WaitOptions) -> Result { + let max_polls = opts.max_polls.unwrap_or(100); + let terminal_states = ["completed", "failed", "timeout", "cancelled"]; + + for i in 0..max_polls { + let delay_idx = std::cmp::min(i, POLL_DELAYS.len() - 1); + std::thread::sleep(std::time::Duration::from_millis(POLL_DELAYS[delay_idx])); + + let job_status = get_job(job_id, opts.public_key.as_deref(), opts.secret_key.as_deref())?; + + if terminal_states.contains(&job_status.status.as_str()) { + if job_status.status == "failed" { + return Err(UnError::ExecutionError { + message: "Job failed".to_string(), + exit_code: None, + stderr: job_status.result.as_ref().map(|r| r.stderr.clone()), + }); + } + if job_status.status == "timeout" { + return Err(UnError::TimeoutError(format!("Job timed out: {}", job_id))); + } + if let Some(result) = job_status.result { + return Ok(result); + } + } + } + + Err(UnError::TimeoutError(format!( + "Max polls ({}) exceeded for job {}", + max_polls, job_id + ))) +} + +pub fn cancel_job(job_id: &str, public_key: Option<&str>, secret_key: Option<&str>) -> Result<()> { + let (pk, sk) = get_credentials(public_key, secret_key, 0)?; + api_request(&format!("/jobs/{}", job_id), "DELETE", None, &pk, &sk)?; + Ok(()) +} + +#[derive(Debug)] +pub struct JobSummary { + pub job_id: String, + pub language: String, + pub status: String, +} + +pub fn list_jobs(public_key: Option<&str>, secret_key: Option<&str>) -> Result> { + let (pk, sk) = get_credentials(public_key, secret_key, 0)?; + let result = api_request("/jobs", "GET", None, &pk, &sk)?; + + // Simple parsing of jobs array - would need proper JSON parser for production + let mut jobs = Vec::new(); + if result.contains("\"job_id\"") { + jobs.push(JobSummary { + job_id: extract_json_string(&result, "job_id"), + language: extract_json_string(&result, "language"), + status: extract_json_string(&result, "status"), + }); + } + + Ok(jobs) +} + +// ============================================================================ +// Languages Cache (1-hour TTL) +// ============================================================================ + +pub fn languages( + public_key: Option<&str>, + secret_key: Option<&str>, + cache_ttl: Option, +) -> Result { + let (pk, sk) = get_credentials(public_key, secret_key, 0)?; + let cache_ttl = cache_ttl.unwrap_or(3600); + + // Check cache + if let Some(home) = dirs::home_dir() { + let cache_path = home.join(".unsandbox").join("languages.json"); + if cache_path.exists() { + if let Ok(metadata) = fs::metadata(&cache_path) { + if let Ok(modified) = metadata.modified() { + if let Ok(elapsed) = modified.elapsed() { + if elapsed.as_secs() < cache_ttl { + if let Ok(content) = fs::read_to_string(&cache_path) { + return Ok(content); + } + } + } + } + } + } + } + + // Fetch from API + let result = api_request("/languages", "GET", None, &pk, &sk)?; + + // Save to cache + if let Some(home) = dirs::home_dir() { + let cache_dir = home.join(".unsandbox"); + let cache_path = cache_dir.join("languages.json"); + let _ = fs::create_dir_all(&cache_dir); + let _ = fs::write(&cache_path, &result); + } + + Ok(result) +} + +// ============================================================================ +// Language Detection +// ============================================================================ + +pub fn detect_language(filename: &str) -> Option<&'static str> { let ext = Path::new(filename) .extension() .and_then(|e| e.to_str()) @@ -106,764 +658,141 @@ fn detect_language(filename: &str) -> Option<&'static str> { "tcl" => Some("tcl"), "raku" => Some("raku"), "m" => Some("objc"), + "awk" => Some("awk"), _ => None, } } -fn get_api_keys(key_arg: Option<&str>) -> (String, String) { - let public_key = env::var("UNSANDBOX_PUBLIC_KEY").ok(); - let secret_key = env::var("UNSANDBOX_SECRET_KEY").ok(); - - // Fall back to UNSANDBOX_API_KEY for backwards compatibility - if public_key.is_none() || secret_key.is_none() { - let fallback_key = if let Some(k) = key_arg { - k.to_string() - } else { - env::var("UNSANDBOX_API_KEY").unwrap_or_else(|_| { - eprintln!("{}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set (or UNSANDBOX_API_KEY for backwards compat){}", RED, RESET); - process::exit(1); - }) - }; - return (fallback_key.clone(), fallback_key); - } - - (public_key.unwrap(), secret_key.unwrap()) -} - -fn compute_hmac(secret_key: &str, timestamp: &str, method: &str, path: &str, body: &str) -> String { - use std::process::Command; - let message = format!("{}:{}:{}:{}", timestamp, method, path, body); - - // Use openssl for HMAC-SHA256 - let output = Command::new("sh") - .arg("-c") - .arg(format!("printf '%s' '{}' | openssl dgst -sha256 -hmac '{}' | cut -d' ' -f2", message, secret_key)) - .output() - .expect("Failed to compute HMAC"); - - String::from_utf8_lossy(&output.stdout).trim().to_string() -} - -fn escape_json(s: &str) -> String { - s.replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n") - .replace('\r', "\\r") - .replace('\t', "\\t") -} - -fn unescape_json(s: &str) -> String { - s.replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\t", "\t") - .replace("\\\"", "\"") - .replace("\\\\", "\\") -} - -fn extract_json_string(json: &str, key: &str) -> String { - let search = format!("\"{}\":\"", key); - if let Some(start) = json.find(&search) { - let start = start + search.len(); - let mut end = start; - let chars: Vec = json.chars().collect(); - while end < chars.len() { - if chars[end] == '"' && (end == 0 || chars[end - 1] != '\\') { - break; - } - end += 1; - } - return unescape_json(&json[start..end]); - } - String::new() -} - -fn extract_json_int(json: &str, key: &str) -> i32 { - let search = format!("\"{}\":", key); - if let Some(pos) = json.find(&search) { - let start = pos + search.len(); - let rest = &json[start..]; - let num_str: String = rest.chars().take_while(|c| c.is_numeric()).collect(); - return num_str.parse().unwrap_or(1); - } - 1 -} - -fn api_request(endpoint: &str, method: &str, body: Option<&str>, public_key: &str, secret_key: &str) -> String { - let url = format!("{}{}", API_BASE, endpoint); - let body_str = body.unwrap_or(""); - - // Compute HMAC signature - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - .to_string(); - let signature = compute_hmac(secret_key, ×tamp, method, endpoint, body_str); - - let mut cmd = Command::new("curl"); - cmd.arg("-s") - .arg("-X") - .arg(method) - .arg(&url) - .arg("-H") - .arg("Content-Type: application/json") - .arg("-H") - .arg(format!("Authorization: Bearer {}", public_key)) - .arg("-H") - .arg(format!("X-Timestamp: {}", timestamp)) - .arg("-H") - .arg(format!("X-Signature: {}", signature)); - - if let Some(b) = body { - cmd.arg("-d").arg(b); - } - - let output = cmd.output().unwrap_or_else(|e| { - eprintln!("{}Error running curl: {}{}", RED, e, RESET); - process::exit(1); - }); - - if !output.status.success() { - eprintln!("{}Error: HTTP request failed{}", RED, RESET); - process::exit(1); - } - - let result = String::from_utf8_lossy(&output.stdout).to_string(); - - // Check for timestamp authentication errors - if result.contains("timestamp") && (result.contains("401") || result.contains("expired") || result.contains("invalid")) { - eprintln!("{}Error: Request timestamp expired (must be within 5 minutes of server time){}", RED, RESET); - eprintln!("{}Your computer's clock may have drifted.{}", YELLOW, RESET); - eprintln!("Check your system time and sync with NTP if needed:"); - eprintln!(" Linux: sudo ntpdate -s time.nist.gov"); - eprintln!(" macOS: sudo sntp -sS time.apple.com"); - eprintln!(" Windows: w32tm /resync"); - process::exit(1); - } - - result -} - -fn api_request_text(endpoint: &str, method: &str, body: &str, public_key: &str, secret_key: &str) -> String { - let url = format!("{}{}", API_BASE, endpoint); - - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() - .to_string(); - let signature = compute_hmac(secret_key, ×tamp, method, endpoint, body); - - let mut cmd = Command::new("curl"); - cmd.arg("-s") - .arg("-X") - .arg(method) - .arg(&url) - .arg("-H") - .arg("Content-Type: text/plain") - .arg("-H") - .arg(format!("Authorization: Bearer {}", public_key)) - .arg("-H") - .arg(format!("X-Timestamp: {}", timestamp)) - .arg("-H") - .arg(format!("X-Signature: {}", signature)); - - if !body.is_empty() { - cmd.arg("-d").arg(body); - } - - let output = cmd.output().unwrap_or_else(|e| { - eprintln!("{}Error running curl: {}{}", RED, e, RESET); - process::exit(1); - }); - - String::from_utf8_lossy(&output.stdout).to_string() -} - -fn read_env_file(path: &str) -> String { - fs::read_to_string(path).unwrap_or_else(|e| { - eprintln!("{}Error reading env file: {}{}", RED, e, RESET); - process::exit(1); - }) -} - -fn build_env_content(envs: &[String], env_file: Option<&str>) -> String { - let mut parts: Vec = Vec::new(); - if let Some(path) = env_file { - parts.push(read_env_file(path).trim().to_string()); - } - for e in envs { - if e.contains('=') { - parts.push(e.clone()); - } - } - parts.join("\n") -} - -fn service_env_status(service_id: &str, public_key: &str, secret_key: &str) -> String { - api_request(&format!("/services/{}/env", service_id), "GET", None, public_key, secret_key) -} - -fn service_env_set(service_id: &str, env_content: &str, public_key: &str, secret_key: &str) -> bool { - api_request_text(&format!("/services/{}/env", service_id), "PUT", env_content, public_key, secret_key); - true -} - -fn service_env_export(service_id: &str, public_key: &str, secret_key: &str) -> String { - api_request(&format!("/services/{}/env/export", service_id), "POST", None, public_key, secret_key) -} - -fn service_env_delete(service_id: &str, public_key: &str, secret_key: &str) -> bool { - api_request(&format!("/services/{}/env", service_id), "DELETE", None, public_key, secret_key); - true -} - -fn cmd_service_env( - action: &str, - target: Option<&str>, - envs: &[String], - env_file: Option<&str>, - public_key: &str, - secret_key: &str, -) { - match action { - "status" => { - let id = target.unwrap_or_else(|| { - eprintln!("{}Error: Usage: service env status {}", RED, RESET); - process::exit(1); - }); - let result = service_env_status(id, public_key, secret_key); - let has_env = result.contains("\"has_env\":true"); - let size = extract_json_int(&result, "size"); - let updated_at = extract_json_string(&result, "updated_at"); - println!("Service: {}", id); - println!("Has Vault: {}", if has_env { "Yes" } else { "No" }); - if has_env { - println!("Size: {} bytes", size); - println!("Updated: {}", updated_at); - } - } - "set" => { - let id = target.unwrap_or_else(|| { - eprintln!("{}Error: Usage: service env set [-e KEY=VAL] [--env-file FILE]{}", RED, RESET); - process::exit(1); - }); - let env_content = build_env_content(envs, env_file); - if env_content.is_empty() { - eprintln!("{}Error: No environment variables specified. Use -e KEY=VAL or --env-file FILE{}", RED, RESET); - process::exit(1); - } - if env_content.len() > 65536 { - eprintln!("{}Error: Environment content exceeds 64KB limit{}", RED, RESET); - process::exit(1); - } - service_env_set(id, &env_content, public_key, secret_key); - println!("{}Vault updated for service: {}{}", GREEN, id, RESET); - } - "export" => { - let id = target.unwrap_or_else(|| { - eprintln!("{}Error: Usage: service env export {}", RED, RESET); - process::exit(1); - }); - let result = service_env_export(id, public_key, secret_key); - let content = extract_json_string(&result, "content"); - if !content.is_empty() { - print!("{}", content); - if !content.ends_with('\n') { - println!(); - } - } else { - eprintln!("{}Vault is empty{}", YELLOW, RESET); - } - } - "delete" => { - let id = target.unwrap_or_else(|| { - eprintln!("{}Error: Usage: service env delete {}", RED, RESET); - process::exit(1); - }); - service_env_delete(id, public_key, secret_key); - println!("{}Vault deleted for service: {}{}", GREEN, id, RESET); - } - _ => { - eprintln!("{}Error: Unknown env action: {}. Use status, set, export, or delete{}", RED, action, RESET); - process::exit(1); - } - } -} +// ============================================================================ +// CLI Interface +// ============================================================================ fn cmd_execute( source_file: &str, envs: Vec, files: Vec, artifacts: bool, - output_dir: Option<&str>, + _output_dir: Option<&str>, network: Option<&str>, vcpu: Option, - public_key: &str, - secret_key: &str, + public_key: Option, + secret_key: Option, ) { - let code = fs::read_to_string(source_file).unwrap_or_else(|e| { - eprintln!("{}Error reading file: {}{}", RED, e, RESET); - process::exit(1); - }); + let code = match fs::read_to_string(source_file) { + Ok(c) => c, + Err(e) => { + eprintln!("{}Error reading file: {}{}", RED, e, RESET); + process::exit(1); + } + }; - let language = detect_language(source_file).unwrap_or_else(|| { - eprintln!("{}Error: Cannot detect language{}", RED, RESET); - process::exit(1); - }); + let language = match detect_language(source_file) { + Some(l) => l, + None => { + eprintln!("{}Error: Cannot detect language{}", RED, RESET); + process::exit(1); + } + }; - let mut json = format!( - r#"{{"language":"{}","code":"{}""#, - language, - escape_json(&code) - ); + let mut opts = ExecuteOptions { + network_mode: network.map(|s| s.to_string()), + ttl: Some(60), + vcpu, + return_artifact: if artifacts { Some(true) } else { None }, + public_key, + secret_key, + ..Default::default() + }; - // Environment variables + // Parse environment variables if !envs.is_empty() { - json.push_str(r#","env":{"#); - for (i, e) in envs.iter().enumerate() { + let mut env_map = HashMap::new(); + for e in envs { if let Some((k, v)) = e.split_once('=') { - if i > 0 { - json.push(','); - } - json.push_str(&format!(r#""{}":"{}""#, k, escape_json(v))); + env_map.insert(k.to_string(), v.to_string()); } } - json.push('}'); + opts.env = Some(env_map); } - // Input files + // Load input files if !files.is_empty() { - json.push_str(r#","input_files":["#); - for (i, f) in files.iter().enumerate() { - let content = fs::read(f).unwrap_or_else(|e| { - eprintln!("{}Error reading input file: {}{}", RED, e, RESET); - process::exit(1); - }); - let b64 = base64::encode(&content); - if i > 0 { - json.push(','); - } - json.push_str(&format!( - r#"{{"filename":"{}","content_base64":"{}"}}"#, - Path::new(f).file_name().unwrap().to_str().unwrap(), - b64 - )); - } - json.push(']'); - } - - if artifacts { - json.push_str(r#","return_artifacts":true"#); - } - if let Some(n) = network { - json.push_str(&format!(r#","network":"{}""#, n)); - } - if let Some(v) = vcpu { - json.push_str(&format!(r#","vcpu":{}"#, v)); - } - - json.push('}'); - - let result = api_request("/execute", "POST", Some(&json), public_key, secret_key); - - // Print output - let stdout_str = extract_json_string(&result, "stdout"); - let stderr_str = extract_json_string(&result, "stderr"); - let exit_code = extract_json_int(&result, "exit_code"); - - if !stdout_str.is_empty() { - print!("{}{}{}", BLUE, stdout_str, RESET); - } - if !stderr_str.is_empty() { - eprint!("{}{}{}", RED, stderr_str, RESET); - } - - // Artifacts (simplified - would need full JSON parsing) - if artifacts && result.contains("artifacts") { - eprintln!("{}Note: Artifact saving not fully implemented in Rust version{}", YELLOW, RESET); - } - - process::exit(exit_code); -} - -fn cmd_session( - list: bool, - kill: Option<&str>, - shell: Option<&str>, - network: Option<&str>, - vcpu: Option, - tmux: bool, - screen: bool, - files: &[String], - public_key: &str, - secret_key: &str, -) { - if list { - let result = api_request("/sessions", "GET", None, public_key, secret_key); - println!("{}", result); - return; - } - - if let Some(id) = kill { - api_request(&format!("/sessions/{}", id), "DELETE", None, public_key, secret_key); - println!("{}Session terminated: {}{}", GREEN, id, RESET); - return; - } - - // Create session - let mut json = format!( - r#"{{"shell":"{}""#, - shell.unwrap_or("bash") - ); - - if let Some(n) = network { - json.push_str(&format!(r#","network":"{}""#, n)); - } - if let Some(v) = vcpu { - json.push_str(&format!(r#","vcpu":{}"#, v)); - } - if tmux { - json.push_str(r#","persistence":"tmux""#); - } - if screen { - json.push_str(r#","persistence":"screen""#); - } - - // Input files - if !files.is_empty() { - json.push_str(r#","input_files":["#); - for (i, f) in files.iter().enumerate() { - if i > 0 { - json.push(','); - } - let content = fs::read(f).unwrap_or_else(|e| { - eprintln!("{}Error reading input file {}: {}{}", RED, f, e, RESET); - process::exit(1); - }); - let b64 = base64::encode(&content); - let filename = Path::new(f).file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); - json.push_str(&format!(r#"{{"filename":"{}","content_base64":"{}"}}"#, filename, b64)); - } - json.push(']'); - } - - json.push('}'); - - println!("{}Creating session...{}", YELLOW, RESET); - let result = api_request("/sessions", "POST", Some(&json), public_key, secret_key); - let id = extract_json_string(&result, "id"); - println!("{}Session created: {}{}", GREEN, id, RESET); -} - -fn cmd_service( - name: Option<&str>, - ports: Option<&str>, - domains: Option<&str>, - service_type: Option<&str>, - bootstrap: Option<&str>, - bootstrap_file: Option<&str>, - files: &[String], - list: bool, - info: Option<&str>, - logs: Option<&str>, - tail: Option<&str>, - sleep: Option<&str>, - wake: Option<&str>, - destroy: Option<&str>, - resize: Option<&str>, - execute: Option<&str>, - command: Option<&str>, - dump_bootstrap: Option<&str>, - dump_file: Option<&str>, - network: Option<&str>, - vcpu: Option, - envs: &[String], - env_file: Option<&str>, - env_action: Option<&str>, - env_target: Option<&str>, - public_key: &str, - secret_key: &str, -) { - // Handle service env subcommand - if let Some(action) = env_action { - cmd_service_env(action, env_target, envs, env_file, public_key, secret_key); - return; - } - - if list { - let result = api_request("/services", "GET", None, public_key, secret_key); - println!("{}", result); - return; - } - - if let Some(id) = info { - let result = api_request(&format!("/services/{}", id), "GET", None, public_key, secret_key); - println!("{}", result); - return; - } - - if let Some(id) = logs { - let result = api_request(&format!("/services/{}/logs", id), "GET", None, public_key, secret_key); - println!("{}", extract_json_string(&result, "logs")); - return; - } - - if let Some(id) = tail { - let result = api_request(&format!("/services/{}/logs?lines=9000", id), "GET", None, public_key, secret_key); - println!("{}", extract_json_string(&result, "logs")); - return; - } - - if let Some(id) = sleep { - api_request(&format!("/services/{}/freeze", id), "POST", None, public_key, secret_key); - println!("{}Service frozen: {}{}", GREEN, id, RESET); - return; - } - - if let Some(id) = wake { - api_request(&format!("/services/{}/unfreeze", id), "POST", None, public_key, secret_key); - println!("{}Service unfreezing: {}{}", GREEN, id, RESET); - return; - } - - if let Some(id) = destroy { - api_request(&format!("/services/{}", id), "DELETE", None, public_key, secret_key); - println!("{}Service destroyed: {}{}", GREEN, id, RESET); - return; - } - - if let Some(id) = resize { - let v = vcpu.unwrap_or_else(|| { - eprintln!("{}Error: --resize requires -v {}", RED, RESET); - process::exit(1); - }); - let json = format!(r#"{{"vcpu":{}}}"#, v); - api_request(&format!("/services/{}", id), "PATCH", Some(&json), public_key, secret_key); - println!("{}Service resized to {} vCPU, {} GB RAM{}", GREEN, v, v * 2, RESET); - return; - } - - if let Some(id) = execute { - let cmd = command.unwrap_or(""); - let json = format!(r#"{{"command":"{}"}}"#, escape_json(cmd)); - let result = api_request(&format!("/services/{}/execute", id), "POST", Some(&json), public_key, secret_key); - let stdout_str = extract_json_string(&result, "stdout"); - let stderr_str = extract_json_string(&result, "stderr"); - if !stdout_str.is_empty() { - print!("{}{}{}", BLUE, stdout_str, RESET); - } - if !stderr_str.is_empty() { - eprint!("{}{}{}", RED, stderr_str, RESET); - } - return; - } - - if let Some(id) = dump_bootstrap { - eprintln!("Fetching bootstrap script from {}...", id); - let json = r#"{"command":"cat /tmp/bootstrap.sh"}"#; - let result = api_request(&format!("/services/{}/execute", id), "POST", Some(json), public_key, secret_key); - let bootstrap = extract_json_string(&result, "stdout"); - - if !bootstrap.is_empty() { - if let Some(file) = dump_file { - match fs::write(file, &bootstrap) { - Ok(_) => { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(file, fs::Permissions::from_mode(0o755)); - } - println!("Bootstrap saved to {}", file); - } - Err(e) => { - eprintln!("{}Error: Could not write to {}: {}{}", RED, file, e, RESET); - process::exit(1); - } + let mut input_files = Vec::new(); + for f in files { + let content = match fs::read(&f) { + Ok(c) => c, + Err(e) => { + eprintln!("{}Error reading input file: {}{}", RED, e, RESET); + process::exit(1); } - } else { - print!("{}", bootstrap); + }; + let b64 = base64_encode(&content); + let filename = Path::new(&f) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + input_files.push(InputFile { + filename, + content_base64: b64, + }); + } + opts.input_files = Some(input_files); + } + + match execute(language, &code, opts) { + Ok(result) => { + if !result.stdout.is_empty() { + print!("{}", result.stdout); } - } else { - eprintln!("{}Error: Failed to fetch bootstrap (service not running or no bootstrap file){}", RED, RESET); + if !result.stderr.is_empty() { + eprint!("{}{}{}", RED, result.stderr, RESET); + } + process::exit(result.exit_code); + } + Err(e) => { + eprintln!("{}Error: {}{}", RED, e, RESET); process::exit(1); } - return; - } - - // Create service - if let Some(n) = name { - let mut json = format!(r#"{{"name":"{}""#, n); - - if let Some(p) = ports { - json.push_str(r#","ports":["#); - let ports_vec: Vec<&str> = p.split(',').collect(); - for (i, port) in ports_vec.iter().enumerate() { - if i > 0 { - json.push(','); - } - json.push_str(port.trim()); - } - json.push(']'); - } - - if let Some(d) = domains { - json.push_str(r#","domains":["#); - let domains_vec: Vec<&str> = d.split(',').collect(); - for (i, domain) in domains_vec.iter().enumerate() { - if i > 0 { - json.push(','); - } - json.push_str(&format!(r#""{}""#, domain.trim())); - } - json.push(']'); - } - - if let Some(t) = service_type { - json.push_str(&format!(r#","service_type":"{}""#, t)); - } - - if let Some(b) = bootstrap { - json.push_str(&format!(r#","bootstrap":"{}""#, escape_json(b))); - } - - if let Some(bf) = bootstrap_file { - if Path::new(bf).exists() { - let content = fs::read_to_string(bf).unwrap_or_else(|e| { - eprintln!("{}Error reading bootstrap file: {}{}", RED, e, RESET); - process::exit(1); - }); - json.push_str(&format!(r#","bootstrap_content":"{}""#, escape_json(&content))); - } else { - eprintln!("{}Error: Bootstrap file not found: {}{}", RED, bf, RESET); - process::exit(1); - } - } - - // Input files - if !files.is_empty() { - json.push_str(r#","input_files":["#); - for (i, f) in files.iter().enumerate() { - if i > 0 { - json.push(','); - } - let content = fs::read(f).unwrap_or_else(|e| { - eprintln!("{}Error reading input file {}: {}{}", RED, f, e, RESET); - process::exit(1); - }); - let b64 = base64::encode(&content); - let filename = Path::new(f).file_name().map(|n| n.to_string_lossy()).unwrap_or_default(); - json.push_str(&format!(r#"{{"filename":"{}","content_base64":"{}"}}"#, filename, b64)); - } - json.push(']'); - } - - if let Some(net) = network { - json.push_str(&format!(r#","network":"{}""#, net)); - } - if let Some(v) = vcpu { - json.push_str(&format!(r#","vcpu":{}"#, v)); - } - - json.push('}'); - - let result = api_request("/services", "POST", Some(&json), public_key, secret_key); - let id = extract_json_string(&result, "id"); - println!("{}Service created: {}{}", GREEN, id, RESET); - - // Auto-set vault if env vars provided - if !id.is_empty() && (!envs.is_empty() || env_file.is_some()) { - let env_content = build_env_content(envs, env_file); - if !env_content.is_empty() && env_content.len() <= 65536 { - if service_env_set(&id, &env_content, public_key, secret_key) { - println!("{}Vault configured with environment variables{}", GREEN, RESET); - } - } - } - return; - } - - eprintln!("{}Error: Specify --name to create a service{}", RED, RESET); - process::exit(1); -} - -fn cmd_key(extend: bool, public_key: &str, secret_key: &str) { - let result = api_request("/keys/validate", "POST", Some("{}"), public_key, secret_key); - - let status = extract_json_string(&result, "status"); - let public_key = extract_json_string(&result, "public_key"); - let tier = extract_json_string(&result, "tier"); - let expired_at = extract_json_string(&result, "expired_at"); - - if extend && !public_key.is_empty() { - let url = format!("{}/keys/extend?pk={}", PORTAL_BASE, public_key); - println!("{}Opening browser: {}{}", YELLOW, url, RESET); - - // Try xdg-open (Linux), open (macOS), or start (Windows) - let _ = Command::new("xdg-open") - .arg(&url) - .spawn() - .or_else(|_| Command::new("open").arg(&url).spawn()) - .or_else(|_| Command::new("cmd").args(&["/c", "start", &url]).spawn()); - - return; - } - - match status.as_str() { - "valid" => { - println!("{}Valid{}", GREEN, RESET); - println!("Public Key: {}", public_key); - println!("Tier: {}", tier); - if !expired_at.is_empty() { - println!("Expires: {}", expired_at); - } - } - "expired" => { - println!("{}Expired{}", RED, RESET); - println!("Public Key: {}", public_key); - println!("Tier: {}", tier); - if !expired_at.is_empty() { - println!("Expired: {}", expired_at); - } - println!("{}To renew: Visit {}/keys/extend{}", YELLOW, PORTAL_BASE, RESET); - } - "invalid" => { - println!("{}Invalid{}", RED, RESET); - } - _ => { - println!("{}Unknown status: {}{}", YELLOW, status, RESET); - } } } -// Minimal base64 encoding -mod base64 { +fn base64_encode(input: &[u8]) -> String { const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + let mut i = 0; - pub fn encode(input: &[u8]) -> String { - let mut result = String::new(); - let mut i = 0; - while i < input.len() { - let b1 = input[i]; - let b2 = if i + 1 < input.len() { input[i + 1] } else { 0 }; - let b3 = if i + 2 < input.len() { input[i + 2] } else { 0 }; + while i < input.len() { + let b1 = input[i]; + let b2 = if i + 1 < input.len() { input[i + 1] } else { 0 }; + let b3 = if i + 2 < input.len() { input[i + 2] } else { 0 }; - result.push(CHARS[(b1 >> 2) as usize] as char); - result.push(CHARS[(((b1 & 0x03) << 4) | (b2 >> 4)) as usize] as char); - result.push(if i + 1 < input.len() { - CHARS[(((b2 & 0x0f) << 2) | (b3 >> 6)) as usize] as char - } else { - '=' - }); - result.push(if i + 2 < input.len() { - CHARS[(b3 & 0x3f) as usize] as char - } else { - '=' - }); + result.push(CHARS[(b1 >> 2) as usize] as char); + result.push(CHARS[(((b1 & 0x03) << 4) | (b2 >> 4)) as usize] as char); + result.push(if i + 1 < input.len() { + CHARS[(((b2 & 0x0f) << 2) | (b3 >> 6)) as usize] as char + } else { + '=' + }); + result.push(if i + 2 < input.len() { + CHARS[(b3 & 0x3f) as usize] as char + } else { + '=' + }); - i += 3; - } - result + i += 3; + } + + result +} + +// Stub for dirs crate +mod dirs { + use std::path::PathBuf; + + pub fn home_dir() -> Option { + std::env::var_os("HOME") + .and_then(|h| if h.is_empty() { None } else { Some(h) }) + .map(PathBuf::from) } } @@ -875,11 +804,13 @@ fn main() { eprintln!(" {} session [options]", args[0]); eprintln!(" {} service [options]", args[0]); eprintln!(" {} key [--extend]", args[0]); + eprintln!("\nLibrary usage: un.rs exports execute(), execute_async(), wait(), etc."); process::exit(1); } - // Parse arguments (simplified) - let mut api_key: Option = None; + // Parse arguments + let mut public_key: Option = None; + let mut secret_key: Option = None; let mut network: Option = None; let mut vcpu: Option = None; let mut envs: Vec = Vec::new(); @@ -891,151 +822,94 @@ fn main() { let mut i = 1; while i < args.len() { match args[i].as_str() { - "-k" => { + "-p" | "--public-key" => { i += 1; if i < args.len() { - api_key = Some(args[i].clone()); + public_key = Some(args[i].clone()); } } - "-n" => { + "-k" | "--secret-key" => { + i += 1; + if i < args.len() { + secret_key = Some(args[i].clone()); + } + } + "-n" | "--network" => { i += 1; if i < args.len() { network = Some(args[i].clone()); } } - "-v" => { + "-v" | "--vcpu" => { i += 1; if i < args.len() { vcpu = args[i].parse().ok(); } } - "-e" => { + "-e" | "--env" => { i += 1; if i < args.len() { envs.push(args[i].clone()); } } - "-f" => { + "-f" | "--file" => { i += 1; if i < args.len() { files.push(args[i].clone()); } } - "-a" => artifacts = true, - "-o" => { + "-a" | "--artifacts" => artifacts = true, + "-o" | "--output" => { i += 1; if i < args.len() { output_dir = Some(args[i].clone()); } } - "session" => { - let (public_key, secret_key) = get_api_keys(api_key.as_deref()); - // Collect -f files for session - let mut session_files: Vec = Vec::new(); - let mut j = i + 1; - while j < args.len() { - if args[j] == "-f" && j + 1 < args.len() { - session_files.push(args[j + 1].clone()); - j += 2; - } else { - j += 1; - } - } - cmd_session( - args.contains(&"--list".to_string()), - args.iter().position(|x| x == "--kill").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--shell").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - network.as_deref(), - vcpu, - args.contains(&"--tmux".to_string()), - args.contains(&"--screen".to_string()), - &session_files, - &public_key, - &secret_key, - ); - return; - } - "service" => { - let (public_key, secret_key) = get_api_keys(api_key.as_deref()); - // Collect -f files and -e envs for service - let mut service_files: Vec = Vec::new(); - let mut service_envs: Vec = Vec::new(); - let mut env_file_opt: Option = None; - let mut env_action: Option = None; - let mut env_target: Option = None; - let mut j = i + 1; - while j < args.len() { - if args[j] == "-f" && j + 1 < args.len() { - service_files.push(args[j + 1].clone()); - j += 2; - } else if args[j] == "-e" && j + 1 < args.len() { - service_envs.push(args[j + 1].clone()); - j += 2; - } else if args[j] == "--env-file" && j + 1 < args.len() { - env_file_opt = Some(args[j + 1].clone()); - j += 2; - } else if args[j] == "env" && env_action.is_none() { - // service env - if j + 1 < args.len() && !args[j + 1].starts_with('-') { - env_action = Some(args[j + 1].clone()); - if j + 2 < args.len() && !args[j + 2].starts_with('-') { - env_target = Some(args[j + 2].clone()); - j += 3; - } else { - j += 2; + "-s" | "--shell" => { + i += 1; + if i < args.len() { + let lang = args[i].clone(); + i += 1; + let code = if i < args.len() { args[i].clone() } else { String::new() }; + + let mut opts = ExecuteOptions { + public_key: public_key.clone(), + secret_key: secret_key.clone(), + network_mode: network, + ttl: Some(60), + vcpu, + ..Default::default() + }; + + if !envs.is_empty() { + let mut env_map = HashMap::new(); + for e in &envs { + if let Some((k, v)) = e.split_once('=') { + env_map.insert(k.to_string(), v.to_string()); } - } else { - j += 1; } - } else { - j += 1; + opts.env = Some(env_map); + } + + match execute(&lang, &code, opts) { + Ok(result) => { + if !result.stdout.is_empty() { + print!("{}", result.stdout); + } + if !result.stderr.is_empty() { + eprint!("{}{}{}", RED, result.stderr, RESET); + } + process::exit(result.exit_code); + } + Err(e) => { + eprintln!("{}Error: {}{}", RED, e, RESET); + process::exit(1); + } } } - cmd_service( - args.iter().position(|x| x == "--name").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--ports").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--domains").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--type").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--bootstrap").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--bootstrap-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - &service_files, - args.contains(&"--list".to_string()), - args.iter().position(|x| x == "--info").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--logs").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--tail").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--freeze").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--unfreeze").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--destroy").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--resize").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--execute").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--command").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--dump-bootstrap").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - args.iter().position(|x| x == "--dump-file").and_then(|p| args.get(p + 1)).map(|s| s.as_str()), - network.as_deref(), - vcpu, - &service_envs, - env_file_opt.as_deref(), - env_action.as_deref(), - env_target.as_deref(), - &public_key, - &secret_key, - ); - return; - } - "key" => { - let (public_key, secret_key) = get_api_keys(api_key.as_deref()); - cmd_key( - args.contains(&"--extend".to_string()), - &public_key, - &secret_key, - ); - return; } _ => { - if args[i].starts_with('-') { - eprintln!("{}Unknown option: {}{}", RED, args[i], RESET); - std::process::exit(1); - } else { + if !args[i].starts_with('-') { source_file = Some(args[i].clone()); } } @@ -1043,20 +917,8 @@ fn main() { i += 1; } - // Execute mode if let Some(file) = source_file { - let (public_key, secret_key) = get_api_keys(api_key.as_deref()); - cmd_execute( - &file, - envs, - files, - artifacts, - output_dir.as_deref(), - network.as_deref(), - vcpu, - &public_key, - &secret_key, - ); + cmd_execute(&file, envs, files, artifacts, output_dir.as_deref(), network.as_deref(), vcpu, public_key, secret_key); } else { eprintln!("{}Error: No source file specified{}", RED, RESET); process::exit(1); diff --git a/un.zig b/un.zig index 65350dc..b8bc0fd 100644 --- a/un.zig +++ b/un.zig @@ -35,14 +35,32 @@ // https://www.unturf.com/software -// UN CLI - Zig Implementation (using curl subprocess for simplicity) +// UN CLI and Library - Zig Implementation (using curl subprocess for simplicity) // Compile: zig build-exe un.zig -O ReleaseFast -// Usage: +// +// Library Usage (Zig): +// pub fn execute(allocator: std.mem.Allocator, language: []const u8, code: []const u8, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn execute_async(allocator: std.mem.Allocator, language: []const u8, code: []const u8, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn get_job(allocator: std.mem.Allocator, job_id: []const u8, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn wait_for_job(allocator: std.mem.Allocator, job_id: []const u8, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn cancel_job(allocator: std.mem.Allocator, job_id: []const u8, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn list_jobs(allocator: std.mem.Allocator, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn get_languages(allocator: std.mem.Allocator, +// public_key: []const u8, secret_key: []const u8) ![]const u8 +// pub fn detect_language(filename: []const u8) ?[]const u8 +// +// CLI Usage: // un.zig script.py // un.zig -e KEY=VALUE script.py // un.zig session --list // un.zig service --name web --ports 8080 - +// // Note: This implementation uses system() to call curl for simplicity // A production version would use Zig's HTTP client library