diff --git a/un.lua b/un.lua index cc68967..e6fa347 100644 --- a/un.lua +++ b/un.lua @@ -2,1099 +2,199 @@ -- 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. +-- at permacomputer.com - an always-on computer by the people, for the people. -- -- 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 --- un.lua - Unsandbox CLI Client (Lua Implementation) --- --- 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 --- --- Usage: --- un.lua [options] --- un.lua session [options] --- un.lua service [options] --- --- Requires: UNSANDBOX_API_KEY environment variable --- Note: Uses curl for HTTP requests (requires curl to be installed) +local json = require("json") +local http = require("socket.http") +local https = require("ssl.https") +local ltn12 = require("ltn12") -local json = require("cjson") +local Un = {} +Un.API_BASE = "https://api.unsandbox.com" +Un.VERSION = "2.0.0" -local API_BASE = "https://api.unsandbox.com" -local PORTAL_BASE = "https://unsandbox.com" -local BLUE = "\27[34m" -local RED = "\27[31m" -local GREEN = "\27[32m" -local YELLOW = "\27[33m" -local RESET = "\27[0m" +-- Credential loading +function Un.load_accounts_csv(path) + path = path or (os.getenv("HOME") .. "/.unsandbox/accounts.csv") + local file = io.open(path, "r") + if not file then return {} end -local 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" -} - -local function get_api_keys(args_key) - local public_key = os.getenv("UNSANDBOX_PUBLIC_KEY") - local secret_key = os.getenv("UNSANDBOX_SECRET_KEY") - - if not public_key or not secret_key then - local old_key = args_key or os.getenv("UNSANDBOX_API_KEY") - if old_key then - public_key = old_key - secret_key = old_key - else - io.stderr:write(RED .. "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" .. RESET .. "\n") - io.stderr:write(RED .. " (or legacy UNSANDBOX_API_KEY for backwards compatibility)" .. RESET .. "\n") - os.exit(1) - end - end - - return {public_key = public_key, secret_key = secret_key} -end - -local function detect_language(filename) - local ext = filename:match("%.([^.]+)$") - if ext then - local lang = EXT_MAP["." .. ext:lower()] - if lang then return lang end - end - - local file = io.open(filename, "r") - if file then - local first_line = file:read("*line") - file:close() - if first_line and first_line:match("^#!") then - if first_line:match("python") then return "python" end - if first_line:match("node") then return "javascript" end - if first_line:match("ruby") then return "ruby" end - if first_line:match("perl") then return "perl" end - if first_line:match("bash") or first_line:match("/sh") then return "bash" end - if first_line:match("lua") then return "lua" end - if first_line:match("php") then return "php" end - end - end - - io.stderr:write(RED .. "Error: Cannot detect language for " .. filename .. RESET .. "\n") - os.exit(1) -end - -local function shell_escape(str) - return "'" .. str:gsub("'", "'\\''") .. "'" -end - -local function api_request(endpoint, method, data, keys) - method = method or "GET" - local url = API_BASE .. endpoint - local tmpfile = os.tmpname() - - -- Generate timestamp and signature - local timestamp = tostring(os.time()) - local body = data and json.encode(data) or "" - - -- Parse URL to get path - local path = endpoint - - -- Create HMAC signature using openssl command - local message = timestamp .. ":" .. method .. ":" .. path .. ":" .. body - local sig_tmpfile = os.tmpname() - local msg_tmpfile = os.tmpname() - - -- Write message to temp file - local f = io.open(msg_tmpfile, "w") - f:write(message) - f:close() - - -- Generate HMAC using openssl - local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" - local sig_handle = io.popen(hmac_cmd) - local signature = sig_handle:read("*a"):gsub("%s+$", "") - sig_handle:close() - os.remove(msg_tmpfile) - - local cmd = "curl -s -X " .. method .. " " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. - " -H 'X-Timestamp: " .. timestamp .. "'" .. - " -H 'X-Signature: " .. signature .. "'" .. - " -H 'Content-Type: application/json'" - - local data_file - if data then - data_file = os.tmpname() - local f = io.open(data_file, "w") - f:write(body) - f:close() - cmd = cmd .. " -d @" .. shell_escape(data_file) - end - - cmd = cmd .. " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) - - local handle = io.popen(cmd) - local http_code = handle:read("*a"):match("(%d+)$") - handle:close() - - local file = io.open(tmpfile, "r") - local response = file:read("*all") - file:close() - os.remove(tmpfile) - - if data_file then - os.remove(data_file) - end - - if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then - if http_code == "401" and response:lower():find("timestamp") then - io.stderr:write(RED .. "Error: Request timestamp expired (must be within 5 minutes of server time)" .. RESET .. "\n") - io.stderr:write(YELLOW .. "Your computer's clock may have drifted." .. RESET .. "\n") - io.stderr:write(YELLOW .. "Check your system time and sync with NTP if needed:" .. RESET .. "\n") - io.stderr:write(" Linux: sudo ntpdate -s time.nist.gov\n") - io.stderr:write(" macOS: sudo sntp -sS time.apple.com\n") - io.stderr:write(" Windows: w32tm /resync\n") - else - io.stderr:write(RED .. "Error: HTTP " .. (http_code or "000") .. " - " .. response .. RESET .. "\n") - end - os.exit(1) - end - - return json.decode(response) -end - -local function api_request_text(endpoint, method, body, keys) - local url = API_BASE .. endpoint - local tmpfile = os.tmpname() - - -- Generate timestamp and signature - local timestamp = tostring(os.time()) - local path = endpoint - - -- Create HMAC signature using openssl command - local message = timestamp .. ":" .. method .. ":" .. path .. ":" .. body - local msg_tmpfile = os.tmpname() - - local f = io.open(msg_tmpfile, "w") - f:write(message) - f:close() - - local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" - local sig_handle = io.popen(hmac_cmd) - local signature = sig_handle:read("*a"):gsub("%s+$", "") - sig_handle:close() - os.remove(msg_tmpfile) - - local data_file = os.tmpname() - local df = io.open(data_file, "w") - df:write(body) - df:close() - - local cmd = "curl -s -X " .. method .. " " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. - " -H 'X-Timestamp: " .. timestamp .. "'" .. - " -H 'X-Signature: " .. signature .. "'" .. - " -H 'Content-Type: text/plain'" .. - " -d @" .. shell_escape(data_file) .. - " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) - - local handle = io.popen(cmd) - local http_code = handle:read("*a"):match("(%d+)$") - handle:close() - - local file = io.open(tmpfile, "r") - local response = file:read("*all") - file:close() - os.remove(tmpfile) - os.remove(data_file) - - if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then - return { error = "HTTP " .. (http_code or "000") .. " - " .. response } - end - - return json.decode(response) -end - --- ============================================================================ --- Environment Secrets Vault Functions --- ============================================================================ - -local MAX_ENV_CONTENT_SIZE = 64 * 1024 -- 64KB max - -local function service_env_status(service_id, keys) - local result = api_request("/services/" .. service_id .. "/env", "GET", nil, keys) - local has_vault = result.has_vault - - if not has_vault then - print("Vault exists: no") - print("Variable count: 0") - else - print("Vault exists: yes") - print("Variable count: " .. (result.count or 0)) - if result.updated_at then - print("Last updated: " .. os.date("%Y-%m-%d %H:%M:%S", result.updated_at)) - end - end -end - -local function service_env_set(service_id, env_content, keys) - if not env_content or env_content == "" then - io.stderr:write(RED .. "Error: No environment content provided" .. RESET .. "\n") - return false - end - - if #env_content > MAX_ENV_CONTENT_SIZE then - io.stderr:write(RED .. "Error: Environment content too large (max " .. MAX_ENV_CONTENT_SIZE .. " bytes)" .. RESET .. "\n") - return false - end - - local result = api_request_text("/services/" .. service_id .. "/env", "PUT", env_content, keys) - - if result.error then - io.stderr:write(RED .. "Error: " .. result.error .. RESET .. "\n") - return false - end - - local count = result.count or 0 - local plural = count == 1 and "" or "s" - print(GREEN .. "Environment vault updated: " .. count .. " variable" .. plural .. RESET) - if result.message then print(result.message) end - return true -end - -local function service_env_export(service_id, keys) - local result = api_request("/services/" .. service_id .. "/env/export", "POST", {}, keys) - local env_content = result.env - if env_content and env_content ~= "" then - io.write(env_content) - if not env_content:match("\n$") then print() end - end -end - -local function service_env_delete(service_id, keys) - api_request("/services/" .. service_id .. "/env", "DELETE", nil, keys) - print(GREEN .. "Environment vault deleted" .. RESET) -end - -local function read_env_file_content(filepath) - local file = io.open(filepath, "r") - if not file then - io.stderr:write(RED .. "Error: Env file not found: " .. filepath .. RESET .. "\n") - os.exit(1) - end - local content = file:read("*all") - file:close() - return content -end - -local function build_env_content(envs, env_file) - local parts = {} - - -- Read from env file first - if env_file and env_file ~= "" then - table.insert(parts, read_env_file_content(env_file)) - end - - -- Add -e flags - for _, e in ipairs(envs) do - if e:find("=") then - table.insert(parts, e) - end - end - - return table.concat(parts, "\n") -end - -local function cmd_service_env(action, target, envs, env_file, keys) - if not action or action == "" then - io.stderr:write(RED .. "Error: env action required (status, set, export, delete)" .. RESET .. "\n") - os.exit(1) - end - - if not target or target == "" then - io.stderr:write(RED .. "Error: Service ID required for env command" .. RESET .. "\n") - os.exit(1) - end - - if action == "status" then - service_env_status(target, keys) - elseif action == "set" then - local env_content = build_env_content(envs, env_file) - if env_content == "" then - io.stderr:write(RED .. "Error: No env content provided. Use -e KEY=VAL or --env-file" .. RESET .. "\n") - os.exit(1) - end - service_env_set(target, env_content, keys) - elseif action == "export" then - service_env_export(target, keys) - elseif action == "delete" then - service_env_delete(target, keys) - else - io.stderr:write(RED .. "Error: Unknown env action '" .. action .. "'. Use: status, set, export, delete" .. RESET .. "\n") - os.exit(1) - end -end - -local function read_file(filename) - local file, err = io.open(filename, "rb") - if not file then - io.stderr:write(RED .. "Error: File not found: " .. filename .. RESET .. "\n") - os.exit(1) - end - local content = file:read("*all") - file:close() - return content -end - -local function base64_encode(data) - local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - return ((data:gsub('.', function(x) - local r,b='',x:byte() - for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end - return r; - end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) - if (#x < 6) then return '' end - local c=0 - for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end - return b64:sub(c+1,c+1) - end)..({ '', '==', '=' })[#data%3+1]) -end - -local function base64_decode(data) - local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - data = string.gsub(data, '[^'..b64..'=]', '') - return (data:gsub('.', function(x) - if (x == '=') then return '' end - local r,f='',(b64:find(x)-1) - for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end - return r; - end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) - if (#x ~= 8) then return '' end - local c=0 - for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end - return string.char(c) - end)) -end - -local function file_exists(filename) - local file = io.open(filename, "r") - if file then - file:close() - return true - end - return false -end - -local function cmd_execute(options) - local keys = get_api_keys(options.api_key) - local code - local language - - -- Check for inline mode: -s/--shell specified, or source_file doesn't exist - if options.exec_shell then - -- Inline mode with specified language - code = options.source_file - language = options.exec_shell - elseif not file_exists(options.source_file) then - -- File doesn't exist - treat as inline bash code - code = options.source_file - language = "bash" - else - -- Normal file execution - code = read_file(options.source_file) - language = detect_language(options.source_file) - end - - local payload = { language = language, code = code } - - if options.env and #options.env > 0 then - local env_vars = {} - for _, e in ipairs(options.env) do - local k, v = e:match("^([^=]+)=(.*)$") - if k and v then - env_vars[k] = v + local accounts = {} + for line in file:lines() do + line = line:match("^%s*(.-)%s*$") + if line ~= "" then + local pk, sk = line:match("([^,]+),(.+)") + if pk and sk then + table.insert(accounts, {pk, sk}) end end - if next(env_vars) then - payload.env = env_vars - end + end + file:close() + return accounts +end + +function Un.get_credentials(opts) + opts = opts or {} + + -- Tier 1: Arguments + if opts.public_key and opts.secret_key then + return opts.public_key, opts.secret_key end - if options.files and #options.files > 0 then - local input_files = {} - for _, filepath in ipairs(options.files) do - local content = read_file(filepath) - table.insert(input_files, { - filename = filepath:match("([^/]+)$"), - content_base64 = base64_encode(content) - }) - end - payload.input_files = input_files - end + -- Tier 2: Environment + local pk = os.getenv("UNSANDBOX_PUBLIC_KEY") + local sk = os.getenv("UNSANDBOX_SECRET_KEY") + if pk and sk then return pk, sk end - if options.artifacts then payload.return_artifacts = true end - if options.network then payload.network = options.network end - if options.vcpu then payload.vcpu = options.vcpu end + -- Tier 3: Home directory + local accounts = Un.load_accounts_csv() + if #accounts > 0 then return accounts[1][1], accounts[1][2] end - local result = api_request("/execute", "POST", payload, keys) + -- Tier 4: Local directory + accounts = Un.load_accounts_csv("./accounts.csv") + if #accounts > 0 then return accounts[1][1], accounts[1][2] end - if result.stdout then - io.write(BLUE .. result.stdout .. RESET) - end - if result.stderr then - io.stderr:write(RED .. result.stderr .. RESET) - end + error("No credentials found") +end - if options.artifacts and result.artifacts then - local out_dir = options.output_dir or "." - os.execute("mkdir -p " .. shell_escape(out_dir)) - for _, artifact in ipairs(result.artifacts) do - local filename = artifact.filename or "artifact" - local content = base64_decode(artifact.content_base64) - local filepath = out_dir .. "/" .. filename - local file = io.open(filepath, "wb") - file:write(content) +-- HMAC signature +function Un.sign_request(secret, timestamp, method, endpoint, body) + local hmac = require("crypto").hmac + local message = timestamp .. ":" .. method .. ":" .. endpoint .. ":" .. body + return hmac.digest("sha256", message, secret, true):hex() +end + +-- API request +function Un.api_request(method, endpoint, body, opts) + opts = opts or {} + local pk, sk = Un.get_credentials(opts) + + local timestamp = tostring(os.time()) + local url = Un.API_BASE .. endpoint + local body_str = body and json.encode(body) or "{}" + local signature = Un.sign_request(sk, timestamp, method, endpoint, body_str) + + local headers = { + ["Authorization"] = "Bearer " .. pk, + ["X-Timestamp"] = timestamp, + ["X-Signature"] = signature, + ["Content-Type"] = "application/json" + } + + local resp_body = {} + local resp, status = https.request({ + url = url, + method = method, + headers = headers, + source = body_str and ltn12.source.string(body_str), + sink = ltn12.sink.table(resp_body) + }) + + if status ~= 200 then error("API error (" .. status .. ")") end + return json.decode(table.concat(resp_body)) +end + +-- Languages with cache +function Un.languages(opts) + opts = opts or {} + local cache_ttl = opts.cache_ttl or 3600 + local cache_path = os.getenv("HOME") .. "/.unsandbox/languages.json" + + local file = io.open(cache_path, "r") + if file then + local mtime = os.time() - (lfs.attributes(cache_path, "modification") or 0) + if mtime < cache_ttl then + local content = file:read("*a") file:close() - os.execute("chmod 755 " .. shell_escape(filepath)) - io.stderr:write(GREEN .. "Saved: " .. filepath .. RESET .. "\n") + return json.decode(content) end + file:close() end + local result = Un.api_request("GET", "/languages", nil, opts) + local langs = result.languages or {} + + os.execute("mkdir -p " .. os.getenv("HOME") .. "/.unsandbox") + file = io.open(cache_path, "w") + file:write(json.encode(langs)) + file:close() + + return langs +end + +-- Execute functions +function Un.execute(language, code, opts) + opts = opts or {} + local body = { + language = language, + code = code, + network_mode = opts.network_mode or "zerotrust", + ttl = opts.ttl or 60 + } + return Un.api_request("POST", "/execute", body, opts) +end + +function Un.execute_async(language, code, opts) + opts = opts or {} + local body = { + language = language, + code = code, + network_mode = opts.network_mode or "zerotrust", + ttl = opts.ttl or 300 + } + return Un.api_request("POST", "/execute/async", body, opts) +end + +function Un.run(file, opts) + local f = io.open(file, "r") + local code = f:read("*a") + f:close() + return Un.execute(Un.detect_language(file), code, opts) +end + +-- Job management +function Un.get_job(job_id, opts) + opts = opts or {} + return Un.api_request("GET", "/jobs/" .. job_id, nil, opts) +end + +function Un.wait(job_id, timeout, opts) + opts = opts or {} + timeout = timeout or 3600 + local delays = {300, 450, 700, 900, 650, 1600, 2000} + + local start = os.time() + for i = 0, 119 do + local job = Un.get_job(job_id, opts) + if job.status == "completed" then return job end + if job.status == "failed" then error("Job failed") end + + if os.time() - start > timeout then error("Polling timeout") end + + local delay = delays[(i % 7) + 1] or 2000 + require("socket").sleep(delay / 1000) + end + + error("Max polls exceeded") +end + +-- Utilities +function Un.detect_language(filename) + local ext = filename:match("%.([^%.]+)$") + local map = {py="python", lua="lua", sh="bash", rb="ruby"} + return map[ext] or error("Unknown file type") +end + +-- CLI +if arg and arg[1] then + local result = Un.run(arg[1]) + if result.stdout then print(result.stdout) end + if result.stderr then io.stderr:write(result.stderr) end os.exit(result.exit_code or 0) end -local function cmd_session(options) - local keys = get_api_keys(options.api_key) - - if options.list then - local result = api_request("/sessions", "GET", nil, keys) - local sessions = result.sessions or {} - if #sessions == 0 then - print("No active sessions") - else - print(string.format("%-40s %-10s %-10s %s", "ID", "Shell", "Status", "Created")) - for _, s in ipairs(sessions) do - print(string.format("%-40s %-10s %-10s %s", - s.id or "N/A", s.shell or "N/A", - s.status or "N/A", s.created_at or "N/A")) - end - end - return - end - - if options.kill then - api_request("/sessions/" .. options.kill, "DELETE", nil, keys) - print(GREEN .. "Session terminated: " .. options.kill .. RESET) - return - end - - if options.attach then - print(YELLOW .. "Attaching to session " .. options.attach .. "..." .. RESET) - print(YELLOW .. "(Interactive sessions require WebSocket - use un2 for full support)" .. RESET) - return - end - - local payload = { shell = options.shell or "bash" } - if options.network then payload.network = options.network end - if options.vcpu then payload.vcpu = options.vcpu end - if options.tmux then payload.persistence = "tmux" end - if options.screen then payload.persistence = "screen" end - if options.audit then payload.audit = true end - - -- Add input files - if options.files and #options.files > 0 then - local input_files = {} - for _, filepath in ipairs(options.files) do - local content = read_file(filepath) - table.insert(input_files, { - filename = filepath:match("([^/]+)$"), - content_base64 = base64_encode(content) - }) - end - payload.input_files = input_files - end - - print(YELLOW .. "Creating session..." .. RESET) - local result = api_request("/sessions", "POST", payload, keys) - print(GREEN .. "Session created: " .. (result.id or "N/A") .. RESET) - print(YELLOW .. "(Interactive sessions require WebSocket - use un2 for full support)" .. RESET) -end - -local function cmd_key(options) - local keys = get_api_keys(options.api_key) - - if options.extend then - -- Get public_key from validation response - local url = PORTAL_BASE .. "/keys/validate" - local tmpfile = os.tmpname() - - local timestamp = tostring(os.time()) - local body = "" - local path = "/keys/validate" - local message = timestamp .. ":POST:" .. path .. ":" .. body - local msg_tmpfile = os.tmpname() - - local f = io.open(msg_tmpfile, "w") - f:write(message) - f:close() - - local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" - local sig_handle = io.popen(hmac_cmd) - local signature = sig_handle:read("*a"):gsub("%s+$", "") - sig_handle:close() - os.remove(msg_tmpfile) - - local cmd = "curl -s -X POST " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. - " -H 'X-Timestamp: " .. timestamp .. "'" .. - " -H 'X-Signature: " .. signature .. "'" .. - " -H 'Content-Type: application/json'" .. - " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) - - local handle = io.popen(cmd) - local http_code = handle:read("*a"):match("(%d+)$") - handle:close() - - local file = io.open(tmpfile, "r") - local response = file:read("*all") - file:close() - os.remove(tmpfile) - - if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then - io.stderr:write(RED .. "Error: HTTP " .. (http_code or "000") .. " - " .. response .. RESET .. "\n") - os.exit(1) - end - - local result = json.decode(response) - local public_key = result.public_key - - if not public_key then - io.stderr:write(RED .. "Error: Could not retrieve public key" .. RESET .. "\n") - os.exit(1) - end - - -- Open browser with extend URL - local extend_url = PORTAL_BASE .. "/keys/extend?pk=" .. public_key - print(GREEN .. "Opening browser to extend key..." .. RESET) - print(extend_url) - os.execute("xdg-open " .. shell_escape(extend_url) .. " 2>/dev/null || open " .. shell_escape(extend_url) .. " 2>/dev/null") - return - end - - -- Validate key (default action) - local url = PORTAL_BASE .. "/keys/validate" - local tmpfile = os.tmpname() - - local timestamp = tostring(os.time()) - local body = "" - local path = "/keys/validate" - local message = timestamp .. ":POST:" .. path .. ":" .. body - local msg_tmpfile = os.tmpname() - - local f = io.open(msg_tmpfile, "w") - f:write(message) - f:close() - - local hmac_cmd = "openssl dgst -sha256 -hmac " .. shell_escape(keys.secret_key) .. " -hex " .. shell_escape(msg_tmpfile) .. " | awk '{print $2}'" - local sig_handle = io.popen(hmac_cmd) - local signature = sig_handle:read("*a"):gsub("%s+$", "") - sig_handle:close() - os.remove(msg_tmpfile) - - local cmd = "curl -s -X POST " .. shell_escape(url) .. - " -H 'Authorization: Bearer " .. keys.public_key .. "'" .. - " -H 'X-Timestamp: " .. timestamp .. "'" .. - " -H 'X-Signature: " .. signature .. "'" .. - " -H 'Content-Type: application/json'" .. - " -w '\\n%{http_code}' -o " .. shell_escape(tmpfile) - - local handle = io.popen(cmd) - local http_code = handle:read("*a"):match("(%d+)$") - handle:close() - - local file = io.open(tmpfile, "r") - local response = file:read("*all") - file:close() - os.remove(tmpfile) - - if not http_code or tonumber(http_code) < 200 or tonumber(http_code) >= 300 then - io.stderr:write(RED .. "Error: Invalid API key" .. RESET .. "\n") - os.exit(1) - end - - local result = json.decode(response) - - if result.status == "valid" then - print(GREEN .. "Valid" .. RESET) - if result.public_key then print("Public Key: " .. result.public_key) end - if result.tier then print("Tier: " .. result.tier) end - if result.expires_at then print("Expires: " .. result.expires_at) end - elseif result.status == "expired" then - print(RED .. "Expired" .. RESET) - if result.public_key then print("Public Key: " .. result.public_key) end - if result.tier then print("Tier: " .. result.tier) end - if result.expired_at then print("Expired: " .. result.expired_at) end - print(YELLOW .. "To renew: Visit https://unsandbox.com/keys/extend" .. RESET) - else - print(RED .. "Invalid" .. RESET) - if result.message then print("Message: " .. result.message) end - end -end - -local function cmd_service(options) - local keys = get_api_keys(options.api_key) - - if options.list then - local result = api_request("/services", "GET", nil, keys) - local services = result.services or {} - if #services == 0 then - print("No services") - else - print(string.format("%-20s %-15s %-10s %-15s %s", "ID", "Name", "Status", "Ports", "Domains")) - for _, s in ipairs(services) do - local ports = table.concat(s.ports or {}, ",") - local domains = table.concat(s.domains or {}, ",") - print(string.format("%-20s %-15s %-10s %-15s %s", - s.id or "N/A", s.name or "N/A", - s.status or "N/A", ports, domains)) - end - end - return - end - - if options.info then - local result = api_request("/services/" .. options.info, "GET", nil, keys) - print(json.encode(result)) - return - end - - if options.logs then - local result = api_request("/services/" .. options.logs .. "/logs", "GET", nil, keys) - print(result.logs or "") - return - end - - if options.tail then - local result = api_request("/services/" .. options.tail .. "/logs?lines=9000", "GET", nil, keys) - print(result.logs or "") - return - end - - if options.sleep then - api_request("/services/" .. options.sleep .. "/freeze", "POST", nil, keys) - print(GREEN .. "Service frozen: " .. options.sleep .. RESET) - return - end - - if options.wake then - api_request("/services/" .. options.wake .. "/unfreeze", "POST", nil, keys) - print(GREEN .. "Service unfreezing: " .. options.wake .. RESET) - return - end - - if options.destroy then - api_request("/services/" .. options.destroy, "DELETE", nil, keys) - print(GREEN .. "Service destroyed: " .. options.destroy .. RESET) - return - end - - if options.resize then - local vcpu = options.resize_vcpu or options.vcpu - if not vcpu then - io.stderr:write(RED .. "Error: --resize requires --vcpu or -v" .. RESET .. "\n") - os.exit(1) - end - if vcpu < 1 or vcpu > 8 then - io.stderr:write(RED .. "Error: vCPU must be between 1 and 8" .. RESET .. "\n") - os.exit(1) - end - local payload = { vcpu = vcpu } - api_request("/services/" .. options.resize, "PATCH", payload, keys) - local ram = vcpu * 2 - print(GREEN .. "Service resized to " .. vcpu .. " vCPU, " .. ram .. " GB RAM" .. RESET) - return - end - - if options.execute then - local payload = { command = options.command } - local result = api_request("/services/" .. options.execute .. "/execute", "POST", payload, keys) - if result.stdout then io.write(BLUE .. result.stdout .. RESET) end - if result.stderr then io.stderr:write(RED .. result.stderr .. RESET) end - return - end - - if options.dump_bootstrap then - io.stderr:write("Fetching bootstrap script from " .. options.dump_bootstrap .. "...\n") - local payload = { command = "cat /tmp/bootstrap.sh" } - local result = api_request("/services/" .. options.dump_bootstrap .. "/execute", "POST", payload, keys) - - if result.stdout then - local bootstrap = result.stdout - if options.dump_file then - -- Write to file - local file = io.open(options.dump_file, "w") - if not file then - io.stderr:write(RED .. "Error: Could not write to " .. options.dump_file .. RESET .. "\n") - os.exit(1) - end - file:write(bootstrap) - file:close() - os.execute("chmod 755 " .. options.dump_file) - print("Bootstrap saved to " .. options.dump_file) - else - -- Print to stdout - io.write(bootstrap) - end - else - io.stderr:write(RED .. "Error: Failed to fetch bootstrap (service not running or no bootstrap file)" .. RESET .. "\n") - os.exit(1) - end - return - end - - if options.name then - local payload = { name = options.name } - if options.ports then - local ports = {} - for p in options.ports:gmatch("[^,]+") do - table.insert(ports, tonumber(p)) - end - payload.ports = ports - end - if options.domains then - local domains = {} - for d in options.domains:gmatch("[^,]+") do - table.insert(domains, d) - end - payload.domains = domains - end - if options.type then - payload.service_type = options.type - end - if options.bootstrap then - payload.bootstrap = options.bootstrap - end - if options.bootstrap_file then - local file = io.open(options.bootstrap_file, "r") - if not file then - io.stderr:write(RED .. "Error: Bootstrap file not found: " .. options.bootstrap_file .. RESET .. "\n") - os.exit(1) - end - payload.bootstrap_content = file:read("*all") - file:close() - end - -- Add input files - if options.files and #options.files > 0 then - local input_files = {} - for _, filepath in ipairs(options.files) do - local content = read_file(filepath) - table.insert(input_files, { - filename = filepath:match("([^/]+)$"), - content_base64 = base64_encode(content) - }) - end - payload.input_files = input_files - end - if options.network then payload.network = options.network end - if options.vcpu then payload.vcpu = options.vcpu end - - local result = api_request("/services", "POST", payload, keys) - local service_id = result.id - print(GREEN .. "Service created: " .. (service_id or "N/A") .. RESET) - print("Name: " .. (result.name or "N/A")) - if result.url then print("URL: " .. result.url) end - - -- Auto-set vault if -e or --env-file provided - local env_content = build_env_content(options.env or {}, options.env_file) - if env_content ~= "" and service_id then - service_env_set(service_id, env_content, keys) - end - return - end - - io.stderr:write(RED .. "Error: Specify --name to create a service, or use --list, --info, etc." .. RESET .. "\n") - os.exit(1) -end - -local function main() - local options = { - command = nil, - source_file = nil, - env = {}, - files = {}, - artifacts = false, - output_dir = nil, - network = nil, - vcpu = nil, - api_key = nil, - shell = nil, - list = false, - attach = nil, - kill = nil, - audit = false, - tmux = false, - screen = false, - name = nil, - ports = nil, - domains = nil, - type = nil, - bootstrap = nil, - bootstrap_file = nil, - info = nil, - logs = nil, - tail = nil, - sleep = nil, - wake = nil, - destroy = nil, - resize = nil, - resize_vcpu = nil, - execute = nil, - command = nil, - dump_bootstrap = nil, - dump_file = nil, - extend = false, - exec_shell = nil, - env_file = nil, - env_action = nil, - env_target = nil - } - - local i = 1 - while i <= #arg do - local a = arg[i] - - if a == "session" or a == "service" or a == "key" then - options.command = a - elseif a == "-e" then - i = i + 1 - table.insert(options.env, arg[i]) - elseif a == "-f" then - i = i + 1 - table.insert(options.files, arg[i]) - elseif a == "-a" then - options.artifacts = true - elseif a == "-o" then - i = i + 1 - options.output_dir = arg[i] - elseif a == "-n" then - i = i + 1 - options.network = arg[i] - elseif a == "-v" then - i = i + 1 - options.vcpu = tonumber(arg[i]) - elseif a == "-k" then - i = i + 1 - options.api_key = arg[i] - elseif a == "-s" or a == "--shell" then - i = i + 1 - -- For session command, this is shell type. For execute, it's inline exec language. - if options.command == "session" then - options.shell = arg[i] - else - options.exec_shell = arg[i] - end - elseif a == "-l" or a == "--list" then - options.list = true - elseif a == "--attach" then - i = i + 1 - options.attach = arg[i] - elseif a == "--kill" then - i = i + 1 - options.kill = arg[i] - elseif a == "--audit" then - options.audit = true - elseif a == "--tmux" then - options.tmux = true - elseif a == "--screen" then - options.screen = true - elseif a == "--name" then - i = i + 1 - options.name = arg[i] - elseif a == "--ports" then - i = i + 1 - options.ports = arg[i] - elseif a == "--domains" then - i = i + 1 - options.domains = arg[i] - elseif a == "--type" then - i = i + 1 - options.type = arg[i] - elseif a == "--bootstrap" then - i = i + 1 - options.bootstrap = arg[i] - elseif a == "--bootstrap-file" then - i = i + 1 - options.bootstrap_file = arg[i] - elseif a == "--env-file" then - i = i + 1 - options.env_file = arg[i] - elseif a == "env" then - -- Handle "service env " subcommand - if options.command == "service" then - i = i + 1 - if i <= #arg then - options.env_action = arg[i] - end - i = i + 1 - if i <= #arg and not arg[i]:match("^%-") then - options.env_target = arg[i] - else - i = i - 1 -- back up if next arg is a flag - end - end - elseif a == "--info" then - i = i + 1 - options.info = arg[i] - elseif a == "--logs" then - i = i + 1 - options.logs = arg[i] - elseif a == "--tail" then - i = i + 1 - options.tail = arg[i] - elseif a == "--freeze" then - i = i + 1 - options.sleep = arg[i] - elseif a == "--unfreeze" then - i = i + 1 - options.wake = arg[i] - elseif a == "--destroy" then - i = i + 1 - options.destroy = arg[i] - elseif a == "--resize" then - i = i + 1 - options.resize = arg[i] - elseif a == "--vcpu" then - i = i + 1 - options.resize_vcpu = tonumber(arg[i]) - elseif a == "--execute" then - i = i + 1 - options.execute = arg[i] - elseif a == "--command" then - i = i + 1 - options.command = arg[i] - elseif a == "--dump-bootstrap" then - i = i + 1 - options.dump_bootstrap = arg[i] - elseif a == "--dump-file" then - i = i + 1 - options.dump_file = arg[i] - elseif a == "--extend" then - options.extend = true - elseif a:match("^%-") then - io.stderr:write(RED .. "Unknown option: " .. a .. RESET .. "\n") - os.exit(1) - else - options.source_file = a - end - - i = i + 1 - end - - if options.command == "session" then - cmd_session(options) - elseif options.command == "service" then - -- Check for "service env" subcommand - if options.env_action then - local keys = get_api_keys(options.api_key) - cmd_service_env(options.env_action, options.env_target, options.env, options.env_file, keys) - else - cmd_service(options) - end - elseif options.command == "key" then - cmd_key(options) - elseif options.source_file then - cmd_execute(options) - else - print([[ -Unsandbox CLI - Execute code in secure sandboxes - -Usage: - ]] .. arg[0] .. [[ [options] - ]] .. arg[0] .. [[ session [options] - ]] .. arg[0] .. [[ service [options] - ]] .. arg[0] .. [[ key [options] - -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 - -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 (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 --vcpu) - --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/renew key -]]) - os.exit(1) - end -end - -main() +return Un diff --git a/un.php b/un.php index 7f0654c..0c44d80 100755 --- a/un.php +++ b/un.php @@ -1,1010 +1,221 @@ -# 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 - -#!/usr/bin/env php - * un.php session [options] - * un.php service [options] - * - * Requires: UNSANDBOX_API_KEY environment variable - */ +// 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 +// +// unsandbox SDK for PHP - Execute code in secure sandboxes +// https://unsandbox.com | https://api.unsandbox.com/openapi -const API_BASE = 'https://api.unsandbox.com'; -const PORTAL_BASE = 'https://unsandbox.com'; -const BLUE = "\033[34m"; -const RED = "\033[31m"; -const GREEN = "\033[32m"; -const YELLOW = "\033[33m"; -const RESET = "\033[0m"; +class Un { + const API_BASE = 'https://api.unsandbox.com'; + const VERSION = '2.0.0'; -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' -]; + public static function loadAccountsCsv($path = null) { + $path = $path ?: getenv('HOME') . '/.unsandbox/accounts.csv'; + if (!file_exists($path)) return []; -function get_api_keys($args_key = null) { - $public_key = getenv('UNSANDBOX_PUBLIC_KEY'); - $secret_key = getenv('UNSANDBOX_SECRET_KEY'); - - if (!$public_key || !$secret_key) { - $old_key = $args_key ?: getenv('UNSANDBOX_API_KEY'); - if ($old_key) { - $public_key = $old_key; - $secret_key = $old_key; - } else { - fwrite(STDERR, RED . "Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set" . RESET . "\n"); - fwrite(STDERR, RED . " (or legacy UNSANDBOX_API_KEY for backwards compatibility)" . RESET . "\n"); - exit(1); + $accounts = []; + $lines = file($path); + foreach ($lines as $line) { + $line = trim($line); + if (!$line) continue; + list($pk, $sk) = explode(',', $line, 2); + $accounts[] = [trim($pk), trim($sk)]; } + return $accounts; } - return ['public_key' => $public_key, 'secret_key' => $secret_key]; -} + public static function getCredentials($publicKey = null, $secretKey = null) { + // Tier 1: Arguments + if ($publicKey && $secretKey) return [$publicKey, $secretKey]; -function detect_language($filename) { - $ext = '.' . strtolower(pathinfo($filename, PATHINFO_EXTENSION)); - $lang = EXT_MAP[$ext] ?? null; - if (!$lang) { - $file = @fopen($filename, 'r'); - if ($file) { - $first_line = fgets($file); - fclose($file); - if (str_starts_with($first_line, '#!')) { - if (str_contains($first_line, 'python')) return 'python'; - if (str_contains($first_line, 'node')) return 'javascript'; - if (str_contains($first_line, 'ruby')) return 'ruby'; - if (str_contains($first_line, 'perl')) return 'perl'; - if (str_contains($first_line, 'bash') || str_contains($first_line, '/sh')) return 'bash'; - if (str_contains($first_line, 'lua')) return 'lua'; - if (str_contains($first_line, 'php')) return 'php'; - } - } - fwrite(STDERR, RED . "Error: Cannot detect language for $filename" . RESET . "\n"); - exit(1); - } - return $lang; -} + // Tier 2: Environment + $pk = getenv('UNSANDBOX_PUBLIC_KEY'); + $sk = getenv('UNSANDBOX_SECRET_KEY'); + if ($pk && $sk) return [$pk, $sk]; -function api_request($endpoint, $method = 'GET', $data = null, $keys = null) { - $url = API_BASE . $endpoint; - $ch = curl_init($url); + // Tier 3: Home directory + $accounts = self::loadAccountsCsv(); + if ($accounts) return $accounts[0]; - $timestamp = (string)time(); - $body = $data ? json_encode($data) : ''; + // Tier 4: Local directory + $accounts = self::loadAccountsCsv('./accounts.csv'); + if ($accounts) return $accounts[0]; - // Parse URL to get path and query - $parsed_url = parse_url($url); - $path = $parsed_url['path'] . (isset($parsed_url['query']) ? '?' . $parsed_url['query'] : ''); - $message = "$timestamp:$method:$path:$body"; - $signature = hash_hmac('sha256', $message, $keys['secret_key']); - - $headers = [ - 'Authorization: Bearer ' . $keys['public_key'], - 'X-Timestamp: ' . $timestamp, - 'X-Signature: ' . $signature, - 'Content-Type: application/json' - ]; - - curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 300 - ]); - - if ($data) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + throw new Exception("No credentials found\n"); } - $response = curl_exec($ch); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($response === false) { - fwrite(STDERR, RED . "Error: " . curl_error($ch) . RESET . "\n"); - curl_close($ch); - exit(1); + public static function signRequest($secret, $timestamp, $method, $endpoint, $body) { + $message = "$timestamp:$method:$endpoint:$body"; + return hash_hmac('sha256', $message, $secret); } - curl_close($ch); + public static function apiRequest($method, $endpoint, $body = null, $opts = []) { + list($pk, $sk) = self::getCredentials( + $opts['publicKey'] ?? null, + $opts['secretKey'] ?? null + ); - if ($http_code < 200 || $http_code >= 300) { - if ($http_code === 401 && stripos($response, 'timestamp') !== false) { - fwrite(STDERR, RED . "Error: Request timestamp expired (must be within 5 minutes of server time)" . RESET . "\n"); - fwrite(STDERR, YELLOW . "Your computer's clock may have drifted." . RESET . "\n"); - fwrite(STDERR, YELLOW . "Check your system time and sync with NTP if needed:" . RESET . "\n"); - fwrite(STDERR, " Linux: sudo ntpdate -s time.nist.gov\n"); - fwrite(STDERR, " macOS: sudo sntp -sS time.apple.com\n"); - fwrite(STDERR, " Windows: w32tm /resync\n"); - } else { - fwrite(STDERR, RED . "Error: HTTP $http_code - $response" . RESET . "\n"); - } - exit(1); - } + $timestamp = time(); + $url = self::API_BASE . $endpoint; + $bodyStr = $body ? json_encode($body) : '{}'; + $signature = self::signRequest($sk, $timestamp, $method, $endpoint, $bodyStr); - return json_decode($response, true); -} - -function api_request_text($endpoint, $method, $body, $keys) { - $url = API_BASE . $endpoint; - $ch = curl_init($url); - - $timestamp = (string)time(); - - // Parse URL to get path - $parsed_url = parse_url($url); - $path = $parsed_url['path']; - $message = "$timestamp:$method:$path:$body"; - $signature = hash_hmac('sha256', $message, $keys['secret_key']); - - $headers = [ - 'Authorization: Bearer ' . $keys['public_key'], - 'X-Timestamp: ' . $timestamp, - 'X-Signature: ' . $signature, - 'Content-Type: text/plain' - ]; - - curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 300, - CURLOPT_POSTFIELDS => $body - ]); - - $response = curl_exec($ch); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($response === false) { - curl_close($ch); - return ['error' => curl_error($ch)]; - } - - curl_close($ch); - - if ($http_code < 200 || $http_code >= 300) { - return ['error' => "HTTP $http_code - $response"]; - } - - return json_decode($response, true); -} - -// ============================================================================ -// Environment Secrets Vault Functions -// ============================================================================ - -const MAX_ENV_CONTENT_SIZE = 64 * 1024; // 64KB max - -function service_env_status($service_id, $keys) { - $result = api_request("/services/$service_id/env", 'GET', null, $keys); - $has_vault = $result['has_vault'] ?? false; - - if (!$has_vault) { - echo "Vault exists: no\n"; - echo "Variable count: 0\n"; - } else { - echo "Vault exists: yes\n"; - echo "Variable count: " . ($result['count'] ?? 0) . "\n"; - if (isset($result['updated_at'])) { - echo "Last updated: " . date('Y-m-d H:i:s', $result['updated_at']) . "\n"; - } - } -} - -function service_env_set($service_id, $env_content, $keys) { - if (empty($env_content)) { - fwrite(STDERR, RED . "Error: No environment content provided" . RESET . "\n"); - return false; - } - - if (strlen($env_content) > MAX_ENV_CONTENT_SIZE) { - fwrite(STDERR, RED . "Error: Environment content too large (max " . MAX_ENV_CONTENT_SIZE . " bytes)" . RESET . "\n"); - return false; - } - - $result = api_request_text("/services/$service_id/env", 'PUT', $env_content, $keys); - - if (isset($result['error'])) { - fwrite(STDERR, RED . "Error: " . $result['error'] . RESET . "\n"); - return false; - } - - $count = $result['count'] ?? 0; - $plural = $count === 1 ? '' : 's'; - echo GREEN . "Environment vault updated: $count variable$plural" . RESET . "\n"; - if (!empty($result['message'])) echo $result['message'] . "\n"; - return true; -} - -function service_env_export($service_id, $keys) { - $result = api_request("/services/$service_id/env/export", 'POST', [], $keys); - $env_content = $result['env'] ?? ''; - if (!empty($env_content)) { - echo $env_content; - if (!str_ends_with($env_content, "\n")) echo "\n"; - } -} - -function service_env_delete($service_id, $keys) { - api_request("/services/$service_id/env", 'DELETE', null, $keys); - echo GREEN . "Environment vault deleted" . RESET . "\n"; -} - -function read_env_file($filepath) { - if (!file_exists($filepath)) { - fwrite(STDERR, RED . "Error: Env file not found: $filepath" . RESET . "\n"); - exit(1); - } - return file_get_contents($filepath); -} - -function build_env_content($envs, $env_file) { - $parts = []; - - // Read from env file first - if (!empty($env_file)) { - $parts[] = read_env_file($env_file); - } - - // Add -e flags - foreach ($envs as $e) { - if (str_contains($e, '=')) { - $parts[] = $e; - } - } - - return implode("\n", $parts); -} - -function cmd_service_env($action, $target, $envs, $env_file, $keys) { - if (empty($action)) { - fwrite(STDERR, RED . "Error: env action required (status, set, export, delete)" . RESET . "\n"); - exit(1); - } - - if (empty($target)) { - fwrite(STDERR, RED . "Error: Service ID required for env command" . RESET . "\n"); - exit(1); - } - - switch ($action) { - case 'status': - service_env_status($target, $keys); - break; - case 'set': - $env_content = build_env_content($envs, $env_file); - if (empty($env_content)) { - fwrite(STDERR, RED . "Error: No env content provided. Use -e KEY=VAL or --env-file" . RESET . "\n"); - exit(1); - } - service_env_set($target, $env_content, $keys); - break; - case 'export': - service_env_export($target, $keys); - break; - case 'delete': - service_env_delete($target, $keys); - break; - default: - fwrite(STDERR, RED . "Error: Unknown env action '$action'. Use: status, set, export, delete" . RESET . "\n"); - exit(1); - } -} - -function cmd_execute($options) { - $keys = get_api_keys($options['api_key']); - - if (!file_exists($options['source_file'])) { - fwrite(STDERR, RED . "Error: File not found: {$options['source_file']}" . RESET . "\n"); - exit(1); - } - - $code = file_get_contents($options['source_file']); - $language = detect_language($options['source_file']); - - $payload = ['language' => $language, 'code' => $code]; - - if (!empty($options['env'])) { - $env_vars = []; - foreach ($options['env'] as $e) { - $parts = explode('=', $e, 2); - if (count($parts) === 2) { - $env_vars[$parts[0]] = $parts[1]; - } - } - if (!empty($env_vars)) { - $payload['env'] = $env_vars; - } - } - - if (!empty($options['files'])) { - $input_files = []; - foreach ($options['files'] as $filepath) { - if (!file_exists($filepath)) { - fwrite(STDERR, RED . "Error: Input file not found: $filepath" . RESET . "\n"); - exit(1); - } - $input_files[] = [ - 'filename' => basename($filepath), - 'content_base64' => base64_encode(file_get_contents($filepath)) - ]; - } - $payload['input_files'] = $input_files; - } - - if ($options['artifacts']) $payload['return_artifacts'] = true; - if ($options['network']) $payload['network'] = $options['network']; - if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; - - $result = api_request('/execute', 'POST', $payload, $keys); - - if (!empty($result['stdout'])) { - echo BLUE . $result['stdout'] . RESET; - } - if (!empty($result['stderr'])) { - fwrite(STDERR, RED . $result['stderr'] . RESET); - } - - if ($options['artifacts'] && !empty($result['artifacts'])) { - $out_dir = $options['output_dir'] ?: '.'; - if (!is_dir($out_dir)) { - mkdir($out_dir, 0755, true); - } - foreach ($result['artifacts'] as $artifact) { - $filename = $artifact['filename'] ?? 'artifact'; - $content = base64_decode($artifact['content_base64']); - $filepath = $out_dir . '/' . $filename; - file_put_contents($filepath, $content); - chmod($filepath, 0755); - fwrite(STDERR, GREEN . "Saved: $filepath" . RESET . "\n"); - } - } - - exit($result['exit_code'] ?? 0); -} - -function cmd_session($options) { - $keys = get_api_keys($options['api_key']); - - if ($options['list']) { - $result = api_request('/sessions', 'GET', null, $keys); - $sessions = $result['sessions'] ?? []; - if (empty($sessions)) { - echo "No active sessions\n"; - } else { - printf("%-40s %-10s %-10s %s\n", 'ID', 'Shell', 'Status', 'Created'); - foreach ($sessions as $s) { - printf("%-40s %-10s %-10s %s\n", - $s['id'] ?? 'N/A', $s['shell'] ?? 'N/A', - $s['status'] ?? 'N/A', $s['created_at'] ?? 'N/A'); - } - } - return; - } - - if ($options['kill']) { - api_request("/sessions/{$options['kill']}", 'DELETE', null, $keys); - echo GREEN . "Session terminated: {$options['kill']}" . RESET . "\n"; - return; - } - - if ($options['attach']) { - echo YELLOW . "Attaching to session {$options['attach']}..." . RESET . "\n"; - echo YELLOW . "(Interactive sessions require WebSocket - use un2 for full support)" . RESET . "\n"; - return; - } - - $payload = ['shell' => $options['shell'] ?: 'bash']; - if ($options['network']) $payload['network'] = $options['network']; - if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; - if ($options['tmux']) $payload['persistence'] = 'tmux'; - if ($options['screen']) $payload['persistence'] = 'screen'; - if ($options['audit']) $payload['audit'] = true; - - // Add input files - if (!empty($options['files'])) { - $input_files = []; - foreach ($options['files'] as $filepath) { - if (!file_exists($filepath)) { - fwrite(STDERR, RED . "Error: Input file not found: $filepath" . RESET . "\n"); - exit(1); - } - $input_files[] = [ - 'filename' => basename($filepath), - 'content_base64' => base64_encode(file_get_contents($filepath)) - ]; - } - $payload['input_files'] = $input_files; - } - - echo YELLOW . "Creating session..." . RESET . "\n"; - $result = api_request('/sessions', 'POST', $payload, $keys); - echo GREEN . "Session created: " . ($result['id'] ?? 'N/A') . RESET . "\n"; - echo YELLOW . "(Interactive sessions require WebSocket - use un2 for full support)" . RESET . "\n"; -} - -function validate_key($keys) { - $url = PORTAL_BASE . '/keys/validate'; - $ch = curl_init($url); - - $timestamp = (string)time(); - $body = ''; - - $parsed_url = parse_url($url); - $path = $parsed_url['path']; - $message = "$timestamp:POST:$path:$body"; - $signature = hash_hmac('sha256', $message, $keys['secret_key']); - - $headers = [ - 'Authorization: Bearer ' . $keys['public_key'], - 'X-Timestamp: ' . $timestamp, - 'X-Signature: ' . $signature, - 'Content-Type: application/json' - ]; - - curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => 'POST', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 30 - ]); - - $response = curl_exec($ch); - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($response === false) { - fwrite(STDERR, RED . "Error: " . curl_error($ch) . RESET . "\n"); - curl_close($ch); - exit(1); - } - - curl_close($ch); - - $data = json_decode($response, true); - - if ($http_code === 200 && isset($data['valid']) && $data['valid']) { - echo GREEN . "Valid" . RESET . "\n\n"; - echo "Public Key: " . ($data['public_key'] ?? 'N/A') . "\n"; - echo "Tier: " . ($data['tier'] ?? 'N/A') . "\n"; - echo "Status: " . ($data['status'] ?? 'N/A') . "\n"; - echo "Expires: " . ($data['expires_at'] ?? 'N/A') . "\n"; - echo "Time Remaining: " . ($data['time_remaining'] ?? 'N/A') . "\n"; - echo "Rate Limit: " . ($data['rate_limit'] ?? 'N/A') . " req/min\n"; - echo "Burst: " . ($data['burst'] ?? 'N/A') . "\n"; - echo "Concurrency: " . ($data['concurrency'] ?? 'N/A') . "\n"; - } elseif ($http_code === 200 && isset($data['valid']) && !$data['valid'] && isset($data['status']) && $data['status'] === 'expired') { - echo RED . "Expired" . RESET . "\n\n"; - echo "Public Key: " . ($data['public_key'] ?? 'N/A') . "\n"; - echo "Tier: " . ($data['tier'] ?? 'N/A') . "\n"; - echo "Expired: " . ($data['expires_at'] ?? 'N/A') . "\n\n"; - echo YELLOW . "To renew: Visit https://unsandbox.com/keys/extend" . RESET . "\n"; - } else { - echo RED . "Invalid" . RESET . "\n\n"; - if (isset($data['error'])) { - echo "Error: " . $data['error'] . "\n"; - } elseif (isset($data['reason'])) { - echo "Reason: " . $data['reason'] . "\n"; - } else { - echo "HTTP $http_code - $response\n"; - } - } -} - -function cmd_key($options) { - $keys = get_api_keys($options['api_key']); - - if ($options['extend']) { - // First validate to get public_key - $url = PORTAL_BASE . '/keys/validate'; $ch = curl_init($url); - - $timestamp = (string)time(); - $body = ''; - - $parsed_url = parse_url($url); - $path = $parsed_url['path']; - $message = "$timestamp:POST:$path:$body"; - $signature = hash_hmac('sha256', $message, $keys['secret_key']); - - $headers = [ - 'Authorization: Bearer ' . $keys['public_key'], + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $pk, 'X-Timestamp: ' . $timestamp, 'X-Signature: ' . $signature, 'Content-Type: application/json' - ]; - - curl_setopt_array($ch, [ - CURLOPT_CUSTOMREQUEST => 'POST', - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => 30 ]); + if ($body) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $bodyStr); + } + $response = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - $data = json_decode($response, true); - $public_key = $data['public_key'] ?? null; + if ($code != 200) throw new Exception("API error ($code)"); + return json_decode($response, true); + } - if (!$public_key) { - fwrite(STDERR, RED . "Error: Could not retrieve public key" . RESET . "\n"); - exit(1); + public static function languages($cacheTtl = 3600) { + $cacheDir = getenv('HOME') . '/.unsandbox'; + $cachePath = $cacheDir . '/languages.json'; + + if (file_exists($cachePath)) { + $age = time() - filemtime($cachePath); + if ($age < $cacheTtl) { + return json_decode(file_get_contents($cachePath), true); + } } - $extend_url = PORTAL_BASE . '/keys/extend?pk=' . urlencode($public_key); - echo "Opening browser to: $extend_url\n"; + $result = self::apiRequest('GET', '/languages'); + $langs = $result['languages'] ?? []; - // Detect platform and open browser - if (PHP_OS_FAMILY === 'Linux') { - exec('xdg-open ' . escapeshellarg($extend_url) . ' > /dev/null 2>&1 &'); - } elseif (PHP_OS_FAMILY === 'Darwin') { - exec('open ' . escapeshellarg($extend_url) . ' > /dev/null 2>&1 &'); - } elseif (PHP_OS_FAMILY === 'Windows') { - exec('start ' . escapeshellarg($extend_url) . ' > NUL 2>&1'); - } else { - echo YELLOW . "Cannot auto-open browser on this platform. Please visit:" . RESET . "\n"; - echo "$extend_url\n"; + if (!is_dir($cacheDir)) mkdir($cacheDir, 0700, true); + file_put_contents($cachePath, json_encode($langs)); + + return $langs; + } + + public static function execute($language, $code, $opts = []) { + $body = [ + 'language' => $language, + 'code' => $code, + 'network_mode' => $opts['networkMode'] ?? 'zerotrust', + 'ttl' => $opts['ttl'] ?? 60 + ]; + return self::apiRequest('POST', '/execute', $body, $opts); + } + + public static function executeAsync($language, $code, $opts = []) { + $body = [ + 'language' => $language, + 'code' => $code, + 'network_mode' => $opts['networkMode'] ?? 'zerotrust', + 'ttl' => $opts['ttl'] ?? 300 + ]; + return self::apiRequest('POST', '/execute/async', $body, $opts); + } + + public static function run($file, $opts = []) { + $code = file_get_contents($file); + return self::execute(self::detectLanguage($file), $code, $opts); + } + + public static function getJob($jobId, $opts = []) { + return self::apiRequest('GET', "/jobs/$jobId", null, $opts); + } + + public static function wait($jobId, $timeout = 3600, $opts = []) { + $delays = [300, 450, 700, 900, 650, 1600, 2000]; + $start = time(); + + for ($i = 0; $i < 120; $i++) { + $job = self::getJob($jobId, $opts); + if ($job['status'] === 'completed') return $job; + if ($job['status'] === 'failed') throw new Exception("Job failed"); + if ($job['status'] === 'timeout') throw new Exception("Job timeout"); + + if (time() - $start > $timeout) throw new Exception("Polling timeout"); + + $delay = $delays[$i] ?? 2000; + usleep($delay * 1000); } - } else { - validate_key($keys); + + throw new Exception("Max polls exceeded"); + } + + public static function cancelJob($jobId, $opts = []) { + return self::apiRequest('DELETE', "/jobs/$jobId", null, $opts); + } + + public static function detectLanguage($filename) { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + $map = ['py' => 'python', 'rb' => 'ruby', 'js' => 'javascript', 'php' => 'php', + 'lua' => 'lua', 'sh' => 'bash', 'go' => 'go', 'pl' => 'perl']; + return $map[$ext] ?? throw new Exception("Unknown file type"); + } + + public static function image($code, $format = 'png', $opts = []) { + return self::apiRequest('POST', '/image', ['code' => $code, 'format' => $format], $opts); } } -function cmd_service($options) { - $keys = get_api_keys($options['api_key']); - - if ($options['list']) { - $result = api_request('/services', 'GET', null, $keys); - $services = $result['services'] ?? []; - if (empty($services)) { - echo "No services\n"; - } else { - printf("%-20s %-15s %-10s %-15s %s\n", 'ID', 'Name', 'Status', 'Ports', 'Domains'); - foreach ($services as $s) { - $ports = implode(',', $s['ports'] ?? []); - $domains = implode(',', $s['domains'] ?? []); - printf("%-20s %-15s %-10s %-15s %s\n", - $s['id'] ?? 'N/A', $s['name'] ?? 'N/A', - $s['status'] ?? 'N/A', $ports, $domains); - } - } - return; +// CLI +if (php_sapi_name() === 'cli' && !empty($GLOBALS['argv'])) { + array_shift($GLOBALS['argv']); + if (empty($GLOBALS['argv'])) { + echo "Usage: php un.php \n"; + exit(1); } - if ($options['info']) { - $result = api_request("/services/{$options['info']}", 'GET', null, $keys); - echo json_encode($result, JSON_PRETTY_PRINT) . "\n"; - return; - } - - if ($options['logs']) { - $result = api_request("/services/{$options['logs']}/logs", 'GET', null, $keys); - echo $result['logs'] ?? ''; - return; - } - - if ($options['tail']) { - $result = api_request("/services/{$options['tail']}/logs?lines=9000", 'GET', null, $keys); - echo $result['logs'] ?? ''; - return; - } - - if ($options['sleep']) { - api_request("/services/{$options['sleep']}/freeze", 'POST', null, $keys); - echo GREEN . "Service frozen: {$options['sleep']}" . RESET . "\n"; - return; - } - - if ($options['wake']) { - api_request("/services/{$options['wake']}/unfreeze", 'POST', null, $keys); - echo GREEN . "Service unfreezing: {$options['wake']}" . RESET . "\n"; - return; - } - - if ($options['destroy']) { - api_request("/services/{$options['destroy']}", 'DELETE', null, $keys); - echo GREEN . "Service destroyed: {$options['destroy']}" . RESET . "\n"; - return; - } - - if ($options['resize']) { - if (!$options['vcpu']) { - fwrite(STDERR, RED . "Error: --vcpu is required with --resize" . RESET . "\n"); - exit(1); - } - $payload = ['vcpu' => $options['vcpu']]; - api_request("/services/{$options['resize']}", 'PATCH', $payload, $keys); - $ram = $options['vcpu'] * 2; - echo GREEN . "Service resized to {$options['vcpu']} vCPU, {$ram} GB RAM" . RESET . "\n"; - return; - } - - if ($options['execute']) { - $payload = ['command' => $options['command']]; - $result = api_request("/services/{$options['execute']}/execute", 'POST', $payload, $keys); - if (!empty($result['stdout'])) echo BLUE . $result['stdout'] . RESET; - if (!empty($result['stderr'])) fwrite(STDERR, RED . $result['stderr'] . RESET); - return; - } - - if ($options['dump_bootstrap']) { - fwrite(STDERR, "Fetching bootstrap script from {$options['dump_bootstrap']}...\n"); - $payload = ['command' => 'cat /tmp/bootstrap.sh']; - $result = api_request("/services/{$options['dump_bootstrap']}/execute", 'POST', $payload, $keys); - - if (!empty($result['stdout'])) { - $bootstrap = $result['stdout']; - if ($options['dump_file']) { - // Write to file - if (file_put_contents($options['dump_file'], $bootstrap) === false) { - fwrite(STDERR, RED . "Error: Could not write to {$options['dump_file']}" . RESET . "\n"); - exit(1); - } - chmod($options['dump_file'], 0755); - echo "Bootstrap saved to {$options['dump_file']}\n"; - } else { - // Print to stdout - echo $bootstrap; - } - } else { - fwrite(STDERR, RED . "Error: Failed to fetch bootstrap (service not running or no bootstrap file)" . RESET . "\n"); - exit(1); - } - return; - } - - if ($options['name']) { - $payload = ['name' => $options['name']]; - if ($options['ports']) { - $payload['ports'] = array_map('intval', explode(',', $options['ports'])); - } - if ($options['domains']) { - $payload['domains'] = explode(',', $options['domains']); - } - if ($options['type']) { - $payload['service_type'] = $options['type']; - } - if ($options['bootstrap']) { - $payload['bootstrap'] = $options['bootstrap']; - } - if ($options['bootstrap_file']) { - if (!file_exists($options['bootstrap_file'])) { - fwrite(STDERR, RED . "Error: Bootstrap file not found: {$options['bootstrap_file']}" . RESET . "\n"); - exit(1); - } - $payload['bootstrap_content'] = file_get_contents($options['bootstrap_file']); - } - // Add input files - if (!empty($options['files'])) { - $input_files = []; - foreach ($options['files'] as $filepath) { - if (!file_exists($filepath)) { - fwrite(STDERR, RED . "Error: Input file not found: $filepath" . RESET . "\n"); - exit(1); - } - $input_files[] = [ - 'filename' => basename($filepath), - 'content_base64' => base64_encode(file_get_contents($filepath)) - ]; - } - $payload['input_files'] = $input_files; - } - if ($options['network']) $payload['network'] = $options['network']; - if ($options['vcpu']) $payload['vcpu'] = $options['vcpu']; - - $result = api_request('/services', 'POST', $payload, $keys); - $service_id = $result['id'] ?? null; - echo GREEN . "Service created: " . ($service_id ?? 'N/A') . RESET . "\n"; - echo "Name: " . ($result['name'] ?? 'N/A') . "\n"; - if (!empty($result['url'])) echo "URL: {$result['url']}\n"; - - // Auto-set vault if -e or --env-file provided - $env_content = build_env_content($options['env'] ?? [], $options['env_file']); - if (!empty($env_content) && $service_id) { - service_env_set($service_id, $env_content, $keys); - } - return; - } - - fwrite(STDERR, RED . "Error: Specify --name to create a service, or use --list, --info, etc." . RESET . "\n"); - exit(1); -} - -function main() { - global $argv; - - $options = [ - 'command' => null, - 'source_file' => null, - 'env' => [], - 'files' => [], - 'artifacts' => false, - 'output_dir' => null, - 'network' => null, - 'vcpu' => null, - 'api_key' => null, - 'shell' => null, - 'list' => false, - 'attach' => null, - 'kill' => null, - 'audit' => false, - 'tmux' => false, - 'screen' => false, - 'name' => null, - 'ports' => null, - 'domains' => null, - 'type' => null, - 'bootstrap' => null, - 'bootstrap_file' => null, - 'info' => null, - 'logs' => null, - 'tail' => null, - 'sleep' => null, - 'wake' => null, - 'destroy' => null, - 'resize' => null, - 'execute' => null, - 'command' => null, - 'dump_bootstrap' => null, - 'dump_file' => null, - 'extend' => false, - 'env_file' => null, - 'env_action' => null, - 'env_target' => null - ]; - - for ($i = 1; $i < count($argv); $i++) { - $arg = $argv[$i]; - - switch ($arg) { - case 'session': - case 'service': - case 'key': - $options['command'] = $arg; - break; - case '-e': - $options['env'][] = $argv[++$i]; - break; - case '-f': - $options['files'][] = $argv[++$i]; - break; - case '-a': - $options['artifacts'] = true; - break; - case '-o': - $options['output_dir'] = $argv[++$i]; - break; - case '-n': - $options['network'] = $argv[++$i]; - break; - case '-v': - $options['vcpu'] = (int)$argv[++$i]; - break; - case '-k': - $options['api_key'] = $argv[++$i]; - break; - case '-s': - case '--shell': - $options['shell'] = $argv[++$i]; - break; - case '-l': - case '--list': - $options['list'] = true; - break; - case '--attach': - $options['attach'] = $argv[++$i]; - break; - case '--kill': - $options['kill'] = $argv[++$i]; - break; - case '--audit': - $options['audit'] = true; - break; - case '--tmux': - $options['tmux'] = true; - break; - case '--screen': - $options['screen'] = true; - break; - case '--name': - $options['name'] = $argv[++$i]; - break; - case '--ports': - $options['ports'] = $argv[++$i]; - break; - case '--domains': - $options['domains'] = $argv[++$i]; - break; - case '--type': - $options['type'] = $argv[++$i]; - break; - case '--bootstrap': - $options['bootstrap'] = $argv[++$i]; - break; - case '--bootstrap-file': - $options['bootstrap_file'] = $argv[++$i]; - break; - case '--env-file': - $options['env_file'] = $argv[++$i]; - break; - case 'env': - // Handle "service env " subcommand - if ($options['command'] === 'service') { - if (isset($argv[$i + 1])) { - $options['env_action'] = $argv[++$i]; - } - if (isset($argv[$i + 1]) && !str_starts_with($argv[$i + 1], '-')) { - $options['env_target'] = $argv[++$i]; - } - } - break; - case '--info': - $options['info'] = $argv[++$i]; - break; - case '--logs': - $options['logs'] = $argv[++$i]; - break; - case '--tail': - $options['tail'] = $argv[++$i]; - break; - case '--freeze': - $options['sleep'] = $argv[++$i]; - break; - case '--unfreeze': - $options['wake'] = $argv[++$i]; - break; - case '--destroy': - $options['destroy'] = $argv[++$i]; - break; - case '--resize': - $options['resize'] = $argv[++$i]; - break; - case '--execute': - $options['execute'] = $argv[++$i]; - break; - case '--command': - $options['command'] = $argv[++$i]; - break; - case '--dump-bootstrap': - $options['dump_bootstrap'] = $argv[++$i]; - break; - case '--dump-file': - $options['dump_file'] = $argv[++$i]; - break; - case '--extend': - $options['extend'] = true; - break; - default: - if (str_starts_with($arg, '-')) { - fwrite(STDERR, RED . "Unknown option: $arg" . RESET . "\n"); - exit(1); - } else { - $options['source_file'] = $arg; - } - break; - } - } - - if ($options['command'] === 'session') { - cmd_session($options); - } elseif ($options['command'] === 'service') { - // Check for "service env" subcommand - if ($options['env_action']) { - $keys = get_api_keys($options['api_key']); - cmd_service_env($options['env_action'], $options['env_target'], $options['env'], $options['env_file'], $keys); - } else { - cmd_service($options); - } - } elseif ($options['command'] === 'key') { - cmd_key($options); - } elseif ($options['source_file']) { - cmd_execute($options); - } else { - echo "Unsandbox CLI - Execute code in secure sandboxes - -Usage: - {$argv[0]} [options] - {$argv[0]} session [options] - {$argv[0]} service [options] - {$argv[0]} key [options] - -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 - -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 (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: - -k KEY API key (or use UNSANDBOX_API_KEY env var) - --extend Open browser to extend/renew key -"; + try { + $result = Un::run($GLOBALS['argv'][0]); + if (!empty($result['stdout'])) echo $result['stdout']; + if (!empty($result['stderr'])) fwrite(STDERR, $result['stderr']); + exit($result['exit_code'] ?? 0); + } catch (Exception $e) { + fwrite(STDERR, "Error: " . $e->getMessage() . "\n"); exit(1); } } - -main(); +?> diff --git a/un.pl b/un.pl index 98409a1..459a64d 100644 --- a/un.pl +++ b/un.pl @@ -1,3 +1,4 @@ +#!/usr/bin/env perl # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # # This is free public domain software for the public good of a permacomputer hosted @@ -33,6 +34,202 @@ # https://www.timehexon.com # https://www.foxhop.net # https://www.unturf.com/software +# +# unsandbox SDK for Perl - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi + +use strict; +use warnings; +use JSON; +use LWP::UserAgent; +use HTTP::Request; +use Digest::HMAC_SHA256 qw(hmac_sha256_hex); +use File::HomeDir; +use Time::HiRes qw(time sleep); + +our $VERSION = "2.0.0"; +our $API_BASE = 'https://api.unsandbox.com'; + +# Credential system +sub load_accounts_csv { + my ($path) = @_; + $path ||= File::HomeDir->my_home . "/.unsandbox/accounts.csv"; + return [] unless -e $path; + + my @accounts; + open my $fh, '<', $path or return []; + while (my $line = <$fh>) { + chomp $line; + next if !$line; + my ($pk, $sk) = split /,/, $line, 2; + push @accounts, [$pk, $sk] if $pk && $sk; + } + close $fh; + return \@accounts; +} + +sub get_credentials { + my (%opts) = @_; + + # Tier 1: Arguments + return ($opts{public_key}, $opts{secret_key}) if $opts{public_key} && $opts{secret_key}; + + # Tier 2: Environment + if ($ENV{UNSANDBOX_PUBLIC_KEY} && $ENV{UNSANDBOX_SECRET_KEY}) { + return ($ENV{UNSANDBOX_PUBLIC_KEY}, $ENV{UNSANDBOX_SECRET_KEY}); + } + + # Tier 3: Home directory + my $home_accounts = load_accounts_csv(); + return @{$home_accounts->[0]} if @$home_accounts; + + # Tier 4: Local directory + my $local_accounts = load_accounts_csv("./accounts.csv"); + return @{$local_accounts->[0]} if @$local_accounts; + + die "No credentials found\n"; +} + +# HMAC signature +sub sign_request { + my ($secret, $timestamp, $method, $endpoint, $body) = @_; + my $message = "$timestamp:$method:$endpoint:$body"; + return hmac_sha256_hex($message, $secret); +} + +# API communication +sub api_request { + my ($method, $endpoint, $body, %opts) = @_; + my ($pk, $sk) = get_credentials(%opts); + + my $timestamp = int(time); + my $body_str = $body ? JSON::to_json($body) : '{}'; + my $signature = sign_request($sk, $timestamp, $method, $endpoint, $body_str); + + my $ua = LWP::UserAgent->new; + my $url = "$API_BASE$endpoint"; + my $req = HTTP::Request->new($method, $url); + + $req->header('Authorization' => "Bearer $pk"); + $req->header('X-Timestamp' => $timestamp); + $req->header('X-Signature' => $signature); + $req->header('Content-Type' => 'application/json'); + $req->content($body_str) if $body; + + my $res = $ua->request($req); + die "API error (" . $res->code . ")\n" unless $res->is_success; + + return JSON::from_json($res->content); +} + +# Languages with cache +sub languages { + my (%opts) = @_; + my $cache_ttl = $opts{cache_ttl} || 3600; + my $cache_path = File::HomeDir->my_home . "/.unsandbox/languages.json"; + + if (-e $cache_path) { + my $age = time - (stat $cache_path)[9]; + if ($age < $cache_ttl) { + open my $fh, '<', $cache_path; + my $content = do { local $/; <$fh> }; + close $fh; + return JSON::from_json($content); + } + } + + my $result = api_request('GET', '/languages', undef, %opts); + my $langs = $result->{languages} || []; + + my $cache_dir = File::HomeDir->my_home . "/.unsandbox"; + mkdir $cache_dir unless -d $cache_dir; + open my $fh, '>', $cache_path; + print $fh JSON::to_json($langs); + close $fh; + + return $langs; +} + +# Execution functions +sub execute { + my ($language, $code, %opts) = @_; + my $body = { + language => $language, + code => $code, + network_mode => $opts{network_mode} || 'zerotrust', + ttl => $opts{ttl} || 60 + }; + return api_request('POST', '/execute', $body, %opts); +} + +sub execute_async { + my ($language, $code, %opts) = @_; + my $body = { + language => $language, + code => $code, + network_mode => $opts{network_mode} || 'zerotrust', + ttl => $opts{ttl} || 300 + }; + return api_request('POST', '/execute/async', $body, %opts); +} + +sub run { + my ($file, %opts) = @_; + open my $fh, '<', $file or die "Can't read $file\n"; + my $code = do { local $/; <$fh> }; + close $fh; + return execute(detect_language($file), $code, %opts); +} + +# Job management +sub get_job { + my ($job_id, %opts) = @_; + return api_request('GET', "/jobs/$job_id", undef, %opts); +} + +sub wait_job { + my ($job_id, %opts) = @_; + my @delays = (300, 450, 700, 900, 650, 1600, 2000); + + for my $i (0..119) { + my $job = get_job($job_id, %opts); + return $job if $job->{status} eq 'completed'; + die "Job failed\n" if $job->{status} eq 'failed'; + + my $delay = $delays[$i] || 2000; + sleep($delay / 1000); + } + + die "Max polls exceeded\n"; +} + +sub cancel_job { + my ($job_id, %opts) = @_; + return api_request('DELETE', "/jobs/$job_id", undef, %opts); +} + +# Utilities +my %ext_map = ( + py => 'python', rb => 'ruby', js => 'javascript', pl => 'perl', + php => 'php', lua => 'lua', sh => 'bash', go => 'go' +); + +sub detect_language { + my ($filename) = @_; + my ($ext) = $filename =~ /\.([^.]+)$/; + return $ext_map{$ext} || die "Unknown file type\n"; +} + +# CLI +sub cli_main { + my @args = @ARGV; + die "Usage: perl un.pl \n" unless @args; + + my $result = run($args[0]); + print $result->{stdout} if $result->{stdout}; + print STDERR $result->{stderr} if $result->{stderr}; + exit($result->{exit_code} || 0); +} #!/usr/bin/env perl # un.pl - Unsandbox CLI Client (Perl Implementation) diff --git a/un.rb b/un.rb index 773ec35..259a886 100644 --- a/un.rb +++ b/un.rb @@ -1,3 +1,4 @@ +#!/usr/bin/env ruby # PUBLIC DOMAIN - NO LICENSE, NO WARRANTY # # This is free public domain software for the public good of a permacomputer hosted @@ -33,21 +34,24 @@ # https://www.timehexon.com # https://www.foxhop.net # https://www.unturf.com/software - -#!/usr/bin/env ruby -# un.rb - Unsandbox CLI Client (Ruby Implementation) # -# 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 +# unsandbox SDK for Ruby - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi # -# Usage: -# un.rb [options] -# un.rb session [options] -# un.rb service [options] +# Library Usage: +# require_relative 'un.rb' +# result = Un.execute("ruby", 'puts "Hello"') +# job = Un.execute_async("ruby", code) +# result = Un.wait(job["job_id"]) # -# Requires: UNSANDBOX_API_KEY environment variable +# CLI Usage: +# ruby un.rb script.rb +# ruby un.rb -s ruby 'puts "Hello"' +# +# 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) require 'json' require 'net/http' @@ -55,1054 +59,293 @@ require 'uri' require 'base64' require 'fileutils' require 'optparse' +require 'openssl' +require 'time' -API_BASE = 'https://api.unsandbox.com' -PORTAL_BASE = 'https://unsandbox.com' -BLUE = "\e[34m" -RED = "\e[31m" -GREEN = "\e[32m" -YELLOW = "\e[33m" -RESET = "\e[0m" +module Un + VERSION = "2.0.0" -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' -}.freeze + API_BASE = 'https://api.unsandbox.com' + PORTAL_BASE = 'https://unsandbox.com' -def get_api_keys(args_key = nil) - public_key = ENV['UNSANDBOX_PUBLIC_KEY'] - secret_key = ENV['UNSANDBOX_SECRET_KEY'] + # Exception classes + class UnsandboxError < StandardError; end + class AuthenticationError < UnsandboxError; end + class ExecutionError < UnsandboxError; end + class APIError < UnsandboxError; end + class TimeoutError < UnsandboxError; end - unless public_key && secret_key - old_key = args_key || ENV['UNSANDBOX_API_KEY'] - if old_key - public_key = old_key - secret_key = old_key + # ======================================================================== + # Credential System (4-tier) + # ======================================================================== + + def self._load_accounts_csv(path = nil) + path = File.expand_path(path) if path + path ||= File.expand_path('~/.unsandbox/accounts.csv') + + return [] unless File.exist?(path) + + accounts = [] + File.readlines(path).each do |line| + line.strip! + next if line.empty? + pk, sk = line.split(',', 2) + accounts << [pk.strip, sk.strip] if pk && sk + end + accounts + end + + def self._get_credentials(public_key = nil, secret_key = nil) + # Tier 1: Function arguments + return [public_key, secret_key] if public_key && secret_key + + # Tier 2: Environment variables + pk = ENV['UNSANDBOX_PUBLIC_KEY'] + sk = ENV['UNSANDBOX_SECRET_KEY'] + return [pk, sk] if pk && sk + + # Tier 3: Home directory + accounts = _load_accounts_csv(File.expand_path('~/.unsandbox/accounts.csv')) + return accounts[0] if accounts.any? + + # Tier 4: Local directory + accounts = _load_accounts_csv('./accounts.csv') + return accounts[0] if accounts.any? + + raise AuthenticationError, "No credentials found. Set UNSANDBOX_PUBLIC_KEY + UNSANDBOX_SECRET_KEY or create ~/.unsandbox/accounts.csv" + end + + # ======================================================================== + # HMAC Signature + # ======================================================================== + + def self._sign_request(secret_key, timestamp, method, endpoint, body) + message = "#{timestamp}:#{method}:#{endpoint}:#{body}" + OpenSSL::HMAC.hexdigest('SHA256', secret_key, message) + end + + # ======================================================================== + # API Communication + # ======================================================================== + + def self._api_request(method, endpoint, body = nil, public_key = nil, secret_key = nil) + pk, sk = _get_credentials(public_key, secret_key) + + timestamp = Time.now.to_i.to_s + url = "#{API_BASE}#{endpoint}" + + body_str = body ? JSON.generate(body) : '{}' + signature = _sign_request(sk, timestamp, method, endpoint, body_str) + + uri = URI(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + + case method + when 'GET' + req = Net::HTTP::Get.new(uri) + when 'POST' + req = Net::HTTP::Post.new(uri) + when 'DELETE' + req = Net::HTTP::Delete.new(uri) else - warn "#{RED}Error: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set#{RESET}" - warn "#{RED} (or legacy UNSANDBOX_API_KEY for backwards compatibility)#{RESET}" - exit 1 + raise ArgumentError, "Unsupported method: #{method}" end - end - { public_key: public_key, secret_key: secret_key } -end + req['Authorization'] = "Bearer #{pk}" + req['X-Timestamp'] = timestamp + req['X-Signature'] = signature + req['Content-Type'] = 'application/json' -def detect_language(filename) - ext = File.extname(filename).downcase - lang = EXT_MAP[ext] - unless lang - begin - first_line = File.open(filename, &:readline) - if first_line.start_with?('#!') - return 'python' if first_line.include?('python') - return 'javascript' if first_line.include?('node') - return 'ruby' if first_line.include?('ruby') - return 'perl' if first_line.include?('perl') - return 'bash' if first_line.include?('bash') || first_line.include?('/sh') - return 'lua' if first_line.include?('lua') - return 'php' if first_line.include?('php') - end - rescue + req.body = body_str if body && method != 'GET' + + response = http.request(req) + + unless response.code.to_i == 200 + raise APIError, "API error (#{response.code}): #{response.body[0..100]}" end - warn "#{RED}Error: Cannot detect language for #{filename}#{RESET}" - exit 1 - end - lang -end -def api_request(endpoint, method: 'GET', data: nil, keys:) - require 'openssl' - - uri = URI("#{API_BASE}#{endpoint}") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 300 - - timestamp = Time.now.to_i.to_s - body = data ? JSON.generate(data) : '' - message = "#{timestamp}:#{method}:#{uri.path}#{uri.query ? "?#{uri.query}" : ''}:#{body}" - signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message) - - request = case method - when 'GET' then Net::HTTP::Get.new(uri) - when 'POST' then Net::HTTP::Post.new(uri) - when 'DELETE' then Net::HTTP::Delete.new(uri) - when 'PATCH' then Net::HTTP::Patch.new(uri) - else raise "Unknown method: #{method}" - end - - request['Authorization'] = "Bearer #{keys[:public_key]}" - request['X-Timestamp'] = timestamp - request['X-Signature'] = signature - request['Content-Type'] = 'application/json' - request.body = body if data - - response = http.request(request) - unless response.is_a?(Net::HTTPSuccess) - if response.code == '401' && response.body.downcase.include?('timestamp') - warn "#{RED}Error: Request timestamp expired (must be within 5 minutes of server time)#{RESET}" - warn "#{YELLOW}Your computer's clock may have drifted.#{RESET}" - warn "#{YELLOW}Check your system time and sync with NTP if needed:#{RESET}" - warn " Linux: sudo ntpdate -s time.nist.gov" - warn " macOS: sudo sntp -sS time.apple.com" - warn " Windows: w32tm /resync" - else - warn "#{RED}Error: HTTP #{response.code} - #{response.body}#{RESET}" - end - exit 1 - end - - JSON.parse(response.body) -rescue => e - warn "#{RED}Error: #{e.message}#{RESET}" - exit 1 -end - -def api_request_text(endpoint, method:, body:, keys:) - require 'openssl' - - uri = URI("#{API_BASE}#{endpoint}") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 300 - - timestamp = Time.now.to_i.to_s - message = "#{timestamp}:#{method}:#{uri.path}:#{body}" - signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message) - - request = case method - when 'PUT' then Net::HTTP::Put.new(uri) - else raise "Unknown method: #{method}" - end - - request['Authorization'] = "Bearer #{keys[:public_key]}" - request['X-Timestamp'] = timestamp - request['X-Signature'] = signature - request['Content-Type'] = 'text/plain' - request.body = body - - response = http.request(request) - unless response.is_a?(Net::HTTPSuccess) - return { 'error' => "HTTP #{response.code} - #{response.body}" } - end - - JSON.parse(response.body) -rescue => e - { 'error' => e.message } -end - -# ============================================================================ -# Environment Secrets Vault Functions -# ============================================================================ - -MAX_ENV_CONTENT_SIZE = 64 * 1024 # 64KB max env vault size - -def service_env_status(service_id, keys) - result = api_request("/services/#{service_id}/env", keys: keys) - has_vault = result['has_vault'] - - if !has_vault - puts "Vault exists: no" - puts "Variable count: 0" - else - puts "Vault exists: yes" - puts "Variable count: #{result['count'] || 0}" - if result['updated_at'] - puts "Last updated: #{Time.at(result['updated_at']).strftime('%Y-%m-%d %H:%M:%S')}" - end - end -end - -def service_env_set(service_id, env_content, keys) - if env_content.nil? || env_content.empty? - warn "#{RED}Error: No environment content provided#{RESET}" - return false - end - - if env_content.bytesize > MAX_ENV_CONTENT_SIZE - warn "#{RED}Error: Environment content too large (max #{MAX_ENV_CONTENT_SIZE} bytes)#{RESET}" - return false - end - - result = api_request_text("/services/#{service_id}/env", method: 'PUT', body: env_content, keys: keys) - - if result['error'] - warn "#{RED}Error: #{result['error']}#{RESET}" - return false - end - - count = result['count'] || 0 - plural = count == 1 ? '' : 's' - puts "#{GREEN}Environment vault updated: #{count} variable#{plural}#{RESET}" - puts result['message'] if result['message'] - true -end - -def service_env_export(service_id, keys) - result = api_request("/services/#{service_id}/env/export", method: 'POST', data: {}, keys: keys) - env_content = result['env'] - if env_content && !env_content.empty? - print env_content - puts unless env_content.end_with?("\n") - end -end - -def service_env_delete(service_id, keys) - api_request("/services/#{service_id}/env", method: 'DELETE', keys: keys) - puts "#{GREEN}Environment vault deleted#{RESET}" -end - -def read_env_file(filepath) - File.read(filepath) -rescue => e - warn "#{RED}Error: Env file not found: #{filepath}#{RESET}" - exit 1 -end - -def build_env_content(envs, env_file) - parts = [] - - # Read from env file first - parts << read_env_file(env_file) if env_file && !env_file.empty? - - # Add -e flags - envs.each do |e| - parts << e if e.include?('=') - end - - parts.join("\n") -end - -def cmd_service_env(action, target, envs, env_file, keys) - if action.nil? || action.empty? - warn "#{RED}Error: env action required (status, set, export, delete)#{RESET}" - exit 1 - end - - if target.nil? || target.empty? - warn "#{RED}Error: Service ID required for env command#{RESET}" - exit 1 - end - - case action - when 'status' - service_env_status(target, keys) - when 'set' - env_content = build_env_content(envs, env_file) - if env_content.empty? - warn "#{RED}Error: No env content provided. Use -e KEY=VAL or --env-file#{RESET}" - exit 1 - end - service_env_set(target, env_content, keys) - when 'export' - service_env_export(target, keys) - when 'delete' - service_env_delete(target, keys) - else - warn "#{RED}Error: Unknown env action '#{action}'. Use: status, set, export, delete#{RESET}" - exit 1 - end -end - -def cmd_execute(options) - keys = get_api_keys(options[:api_key]) - - # Check for inline mode: -s/--shell specified, or source_file doesn't exist - if options[:exec_shell] - # Inline mode with specified language - code = options[:source_file] - language = options[:exec_shell] - elsif !File.exist?(options[:source_file]) - # File doesn't exist - treat as inline bash code - code = options[:source_file] - language = "bash" - else - # Normal file execution - code = File.read(options[:source_file]) - language = detect_language(options[:source_file]) - end - - payload = { language: language, code: code } - - if options[:env] && !options[:env].empty? - env_vars = {} - options[:env].each do |e| - k, v = e.split('=', 2) - env_vars[k] = v if k && v - end - payload[:env] = env_vars unless env_vars.empty? - end - - if options[:files] && !options[:files].empty? - input_files = options[:files].map do |filepath| - unless File.exist?(filepath) - warn "#{RED}Error: Input file not found: #{filepath}#{RESET}" - exit 1 - end - { - filename: File.basename(filepath), - content_base64: Base64.strict_encode64(File.read(filepath, mode: 'rb')) - } - end - payload[:input_files] = input_files - end - - payload[:return_artifacts] = true if options[:artifacts] - payload[:network] = options[:network] if options[:network] - payload[:vcpu] = options[:vcpu] if options[:vcpu] - - result = api_request('/execute', method: 'POST', data: payload, keys: keys) - - print "#{BLUE}#{result['stdout']}#{RESET}" if result['stdout'] - $stderr.print "#{RED}#{result['stderr']}#{RESET}" if result['stderr'] - - if options[:artifacts] && result['artifacts'] - out_dir = options[:output_dir] || '.' - FileUtils.mkdir_p(out_dir) unless Dir.exist?(out_dir) - result['artifacts'].each do |artifact| - filename = artifact['filename'] || 'artifact' - content = Base64.strict_decode64(artifact['content_base64']) - filepath = File.join(out_dir, filename) - File.write(filepath, content, mode: 'wb') - File.chmod(0755, filepath) - warn "#{GREEN}Saved: #{filepath}#{RESET}" - end - end - - exit(result['exit_code'] || 0) -end - -def cmd_session(options) - keys = get_api_keys(options[:api_key]) - - if options[:list] - result = api_request('/sessions', keys: keys) - sessions = result['sessions'] || [] - if sessions.empty? - puts 'No active sessions' - else - puts format('%-40s %-10s %-10s %s', 'ID', 'Shell', 'Status', 'Created') - sessions.each do |s| - puts format('%-40s %-10s %-10s %s', - s['id'] || 'N/A', s['shell'] || 'N/A', - s['status'] || 'N/A', s['created_at'] || 'N/A') - end - end - return - end - - if options[:kill] - api_request("/sessions/#{options[:kill]}", method: 'DELETE', keys: keys) - puts "#{GREEN}Session terminated: #{options[:kill]}#{RESET}" - return - end - - if options[:snapshot_session] - payload = {} - payload[:name] = options[:snapshot_name] if options[:snapshot_name] - payload[:hot] = true if options[:hot] - - warn "#{YELLOW}Creating snapshot of session #{options[:snapshot_session]}...#{RESET}" - result = api_request("/sessions/#{options[:snapshot_session]}/snapshot", method: 'POST', data: payload, keys: keys) - puts "#{GREEN}Snapshot created successfully#{RESET}" - puts "Snapshot ID: #{result['id'] || 'N/A'}" - return - end - - if options[:restore_session] - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - warn "#{YELLOW}Restoring from snapshot #{options[:restore_session]}...#{RESET}" - result = api_request("/snapshots/#{options[:restore_session]}/restore", method: 'POST', keys: keys) - puts "#{GREEN}Session restored from snapshot#{RESET}" - puts "New session ID: #{result['session_id']}" if result['session_id'] - return - end - - if options[:attach] - puts "#{YELLOW}Attaching to session #{options[:attach]}...#{RESET}" - puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}" - return - end - - payload = { shell: options[:shell] || 'bash' } - payload[:network] = options[:network] if options[:network] - payload[:vcpu] = options[:vcpu] if options[:vcpu] - payload[:persistence] = 'tmux' if options[:tmux] - payload[:persistence] = 'screen' if options[:screen] - payload[:audit] = true if options[:audit] - - # Add input files - if options[:files] && !options[:files].empty? - input_files = options[:files].map do |filepath| - unless File.exist?(filepath) - warn "#{RED}Error: Input file not found: #{filepath}#{RESET}" - exit 1 - end - { - filename: File.basename(filepath), - content_base64: Base64.strict_encode64(File.read(filepath, mode: 'rb')) - } - end - payload[:input_files] = input_files - end - - puts "#{YELLOW}Creating session...#{RESET}" - result = api_request('/sessions', method: 'POST', data: payload, keys: keys) - puts "#{GREEN}Session created: #{result['id'] || 'N/A'}#{RESET}" - puts "#{YELLOW}(Interactive sessions require WebSocket - use un2 for full support)#{RESET}" -end - -def validate_key(keys) - require 'openssl' - - uri = URI("#{PORTAL_BASE}/keys/validate") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - timestamp = Time.now.to_i.to_s - body = '' - message = "#{timestamp}:POST:#{uri.path}:#{body}" - signature = OpenSSL::HMAC.hexdigest('SHA256', keys[:secret_key], message) - - request = Net::HTTP::Post.new(uri) - request['Authorization'] = "Bearer #{keys[:public_key]}" - request['X-Timestamp'] = timestamp - request['X-Signature'] = signature - request['Content-Type'] = 'application/json' - - response = http.request(request) - - begin - result = JSON.parse(response.body) + JSON.parse(response.body) rescue JSON::ParserError => e - warn "#{RED}Error: Failed to parse response: #{e.message}#{RESET}" - exit 1 + raise APIError, "Invalid API response: #{e.message}" end - if response.is_a?(Net::HTTPSuccess) && result['valid'] - puts "#{GREEN}Valid#{RESET}" - puts "Public Key: #{result['public_key']}" - puts "Tier: #{result['tier']}" - puts "Status: #{result['status']}" - puts "Expires: #{result['expires_at']}" - puts "Time Remaining: #{result['time_remaining']}" - puts "Rate Limit: #{result['rate_limit']}" - puts "Burst: #{result['burst']}" - puts "Concurrency: #{result['concurrency']}" - result - elsif result['expired'] - puts "#{RED}Expired#{RESET}" - puts "Public Key: #{result['public_key']}" - puts "Tier: #{result['tier']}" - puts "Expired: #{result['expires_at']}" - puts "#{YELLOW}To renew: Visit https://unsandbox.com/keys/extend#{RESET}" - result - else - puts "#{RED}Invalid#{RESET}" - puts "Error: #{result['error'] || result['reason'] || 'Unknown error'}" - exit 1 - end -rescue => e - warn "#{RED}Error: #{e.message}#{RESET}" - exit 1 -end + # ======================================================================== + # Languages Cache (1-hour TTL) + # ======================================================================== -def open_browser(url) - case RbConfig::CONFIG['host_os'] - when /mswin|mingw|cygwin/ - system("start #{url}") - when /darwin/ - system("open #{url}") - when /linux|bsd/ - system("xdg-open #{url}") - else - puts "#{YELLOW}Please open this URL in your browser:#{RESET}" - puts url - end -end + def self.languages(cache_ttl = 3600) + cache_path = File.expand_path('~/.unsandbox/languages.json') -def cmd_key(options) - keys = get_api_keys(options[:api_key]) - - if options[:extend] - result = validate_key(keys) - public_key = result['public_key'] - if public_key - url = "#{PORTAL_BASE}/keys/extend?pk=#{public_key}" - puts "#{GREEN}Opening browser to extend key...#{RESET}" - open_browser(url) - else - warn "#{RED}Error: Could not retrieve public key#{RESET}" - exit 1 + # Check cache + if File.exist?(cache_path) + age = Time.now.to_i - File.stat(cache_path).mtime.to_i + return JSON.parse(File.read(cache_path)) if age < cache_ttl end - else - validate_key(keys) + + # Fetch from API + result = _api_request('GET', '/languages') + langs = result['languages'] || [] + + # Update cache + FileUtils.mkdir_p(File.dirname(cache_path)) + File.write(cache_path, JSON.generate(langs)) + + langs end -end -def cmd_snapshot(options) - keys = get_api_keys(options[:api_key]) + # ======================================================================== + # Core Execution Functions + # ======================================================================== - if options[:list] - result = api_request('/snapshots', keys: keys) - snapshots = result['snapshots'] || [] - if snapshots.empty? - puts 'No snapshots found' - else - puts format('%-40s %-20s %-12s %-30s %s', 'ID', 'Name', 'Type', 'Source ID', 'Size') - snapshots.each do |s| - puts format('%-40s %-20s %-12s %-30s %s', - s['id'] || 'N/A', s['name'] || '-', - s['source_type'] || 'N/A', s['source_id'] || 'N/A', - s['size'] || 'N/A') + def self.execute(language, code, opts = {}) + body = { + 'language' => language, + 'code' => code, + 'network_mode' => opts[:network_mode] || 'zerotrust', + 'ttl' => opts[:ttl] || 60 + } + body['env'] = opts[:env] if opts[:env] + + _api_request('POST', '/execute', body, opts[:public_key], opts[:secret_key]) + end + + def self.execute_async(language, code, opts = {}) + body = { + 'language' => language, + 'code' => code, + 'network_mode' => opts[:network_mode] || 'zerotrust', + 'ttl' => opts[:ttl] || 300 + } + body['env'] = opts[:env] if opts[:env] + + _api_request('POST', '/execute/async', body, opts[:public_key], opts[:secret_key]) + end + + def self.run(file_path, opts = {}) + code = File.read(file_path) + execute(detect_language(file_path), code, opts) + end + + def self.run_async(file_path, opts = {}) + code = File.read(file_path) + execute_async(detect_language(file_path), code, opts) + end + + # ======================================================================== + # Job Management + # ======================================================================== + + def self.get_job(job_id, opts = {}) + _api_request('GET', "/jobs/#{job_id}", nil, opts[:public_key], opts[:secret_key]) + end + + def self.wait(job_id, timeout = 3600, opts = {}) + start_time = Time.now + delays = [300, 450, 700, 900, 650, 1600, 2000] + max_polls = 120 + + max_polls.times do |i| + job = get_job(job_id, opts) + status = job['status'] + + if status == 'completed' + return job + elsif status == 'failed' + raise ExecutionError, "Job failed: #{job['error']}" + elsif status == 'cancelled' + raise ExecutionError, "Job was cancelled" + elsif status == 'timeout' + raise TimeoutError, "Job timed out" end - end - return - end - if options[:info_snapshot] - result = api_request("/snapshots/#{options[:info_snapshot]}", keys: keys) - puts "#{BLUE}Snapshot Details#{RESET}\n" - puts "Snapshot ID: #{result['id'] || 'N/A'}" - puts "Name: #{result['name'] || '-'}" - puts "Source Type: #{result['source_type'] || 'N/A'}" - puts "Source ID: #{result['source_id'] || 'N/A'}" - puts "Size: #{result['size'] || 'N/A'}" - puts "Created: #{result['created_at'] || 'N/A'}" - return - end - - if options[:delete_snapshot] - api_request("/snapshots/#{options[:delete_snapshot]}", method: 'DELETE', keys: keys) - puts "#{GREEN}Snapshot deleted successfully#{RESET}" - return - end - - if options[:clone_snapshot] - unless options[:clone_type] - warn "#{RED}Error: --type required for --clone (session or service)#{RESET}" - exit 1 - end - unless ['session', 'service'].include?(options[:clone_type]) - warn "#{RED}Error: --type must be 'session' or 'service'#{RESET}" - exit 1 - end - - payload = { type: options[:clone_type] } - payload[:name] = options[:clone_name] if options[:clone_name] - payload[:shell] = options[:clone_shell] if options[:clone_shell] - payload[:ports] = options[:clone_ports].split(',').map(&:to_i) if options[:clone_ports] - - result = api_request("/snapshots/#{options[:clone_snapshot]}/clone", method: 'POST', data: payload, keys: keys) - - if options[:clone_type] == 'session' - puts "#{GREEN}Session created from snapshot#{RESET}" - puts "Session ID: #{result['id'] || 'N/A'}" - else - puts "#{GREEN}Service created from snapshot#{RESET}" - puts "Service ID: #{result['id'] || 'N/A'}" - end - return - end - - warn "#{RED}Error: Specify --list, --info ID, --delete ID, or --clone ID --type TYPE#{RESET}" - exit 1 -end - -def cmd_service(options) - keys = get_api_keys(options[:api_key]) - - if options[:list] - result = api_request('/services', keys: keys) - services = result['services'] || [] - if services.empty? - puts 'No services' - else - puts format('%-20s %-15s %-10s %-15s %s', 'ID', 'Name', 'Status', 'Ports', 'Domains') - services.each do |s| - ports = (s['ports'] || []).join(',') - domains = (s['domains'] || []).join(',') - puts format('%-20s %-15s %-10s %-15s %s', - s['id'] || 'N/A', s['name'] || 'N/A', - s['status'] || 'N/A', ports, domains) + if Time.now - start_time > timeout + raise TimeoutError, "Polling timeout after #{timeout}s" end + + delay_ms = delays[i] || 2000 + sleep(delay_ms / 1000.0) end - return + + raise TimeoutError, "Max polls exceeded for job #{job_id}" end - if options[:info] - result = api_request("/services/#{options[:info]}", keys: keys) - puts JSON.pretty_generate(result) - return + def self.cancel_job(job_id, opts = {}) + _api_request('DELETE', "/jobs/#{job_id}", nil, opts[:public_key], opts[:secret_key]) end - if options[:logs] - result = api_request("/services/#{options[:logs]}/logs", keys: keys) - puts result['logs'] || '' - return + def self.list_jobs(opts = {}) + result = _api_request('GET', '/jobs', nil, opts[:public_key], opts[:secret_key]) + result['jobs'] || [] end - if options[:tail] - result = api_request("/services/#{options[:tail]}/logs?lines=9000", keys: keys) - puts result['logs'] || '' - return - end + # ======================================================================== + # Utilities + # ======================================================================== - if options[:sleep] - api_request("/services/#{options[:sleep]}/freeze", method: 'POST', keys: keys) - puts "#{GREEN}Service frozen: #{options[:sleep]}#{RESET}" - return - end - - if options[:wake] - api_request("/services/#{options[:wake]}/unfreeze", method: 'POST', keys: keys) - puts "#{GREEN}Service unfreezing: #{options[:wake]}#{RESET}" - return - end - - if options[:destroy] - api_request("/services/#{options[:destroy]}", method: 'DELETE', keys: keys) - puts "#{GREEN}Service destroyed: #{options[:destroy]}#{RESET}" - return - end - - if options[:resize] - unless options[:vcpu] - warn "#{RED}Error: --vcpu is required with --resize#{RESET}" - exit 1 - end - payload = { vcpu: options[:vcpu] } - api_request("/services/#{options[:resize]}", method: 'PATCH', data: payload, keys: keys) - ram = options[:vcpu] * 2 - puts "#{GREEN}Service resized to #{options[:vcpu]} vCPU, #{ram} GB RAM#{RESET}" - return - end - - if options[:snapshot_service] - payload = {} - payload[:name] = options[:snapshot_name] if options[:snapshot_name] - payload[:hot] = true if options[:hot] - - warn "#{YELLOW}Creating snapshot of service #{options[:snapshot_service]}...#{RESET}" - result = api_request("/services/#{options[:snapshot_service]}/snapshot", method: 'POST', data: payload, keys: keys) - puts "#{GREEN}Snapshot created successfully#{RESET}" - puts "Snapshot ID: #{result['id'] || 'N/A'}" - return - end - - if options[:restore_service] - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - warn "#{YELLOW}Restoring from snapshot #{options[:restore_service]}...#{RESET}" - result = api_request("/snapshots/#{options[:restore_service]}/restore", method: 'POST', keys: keys) - puts "#{GREEN}Service restored from snapshot#{RESET}" - puts "New service ID: #{result['service_id']}" if result['service_id'] - return - end - - if options[:execute] - payload = { command: options[:command] } - result = api_request("/services/#{options[:execute]}/execute", method: 'POST', data: payload, keys: keys) - print "#{BLUE}#{result['stdout']}#{RESET}" if result['stdout'] - $stderr.print "#{RED}#{result['stderr']}#{RESET}" if result['stderr'] - return - end - - if options[:dump_bootstrap] - warn "Fetching bootstrap script from #{options[:dump_bootstrap]}..." - payload = { command: 'cat /tmp/bootstrap.sh' } - result = api_request("/services/#{options[:dump_bootstrap]}/execute", method: 'POST', data: payload, keys: keys) - - if result['stdout'] - bootstrap = result['stdout'] - if options[:dump_file] - # Write to file - begin - File.write(options[:dump_file], bootstrap) - File.chmod(0755, options[:dump_file]) - puts "Bootstrap saved to #{options[:dump_file]}" - rescue => e - warn "#{RED}Error: Could not write to #{options[:dump_file]}: #{e.message}#{RESET}" - exit 1 - end - else - # Print to stdout - print bootstrap - end - else - warn "#{RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)#{RESET}" - exit 1 - end - return - end - - if options[:name] - payload = { name: options[:name] } - payload[:ports] = options[:ports].split(',').map(&:to_i) if options[:ports] - payload[:domains] = options[:domains].split(',') if options[:domains] - payload[:service_type] = options[:type] if options[:type] - payload[:bootstrap] = options[:bootstrap] if options[:bootstrap] - if options[:bootstrap_file] - unless File.exist?(options[:bootstrap_file]) - warn "#{RED}Error: Bootstrap file not found: #{options[:bootstrap_file]}#{RESET}" - exit 1 - end - payload[:bootstrap_content] = File.read(options[:bootstrap_file]) - end - # Add input files - if options[:files] && !options[:files].empty? - input_files = options[:files].map do |filepath| - unless File.exist?(filepath) - warn "#{RED}Error: Input file not found: #{filepath}#{RESET}" - exit 1 - end - { - filename: File.basename(filepath), - content_base64: Base64.strict_encode64(File.read(filepath, mode: 'rb')) - } - end - payload[:input_files] = input_files - end - payload[:network] = options[:network] if options[:network] - payload[:vcpu] = options[:vcpu] if options[:vcpu] - - result = api_request('/services', method: 'POST', data: payload, keys: keys) - service_id = result['id'] - puts "#{GREEN}Service created: #{service_id || 'N/A'}#{RESET}" - puts "Name: #{result['name'] || 'N/A'}" - puts "URL: #{result['url']}" if result['url'] - - # Auto-set vault if -e or --env-file provided - env_content = build_env_content(options[:env] || [], options[:env_file]) - if !env_content.empty? && service_id - service_env_set(service_id, env_content, keys) - end - return - end - - warn "#{RED}Error: Specify --name to create a service, or use --list, --info, etc.#{RESET}" - exit 1 -end - -def main - options = { - command: nil, - source_file: nil, - env: [], - files: [], - artifacts: false, - output_dir: nil, - network: nil, - vcpu: nil, - api_key: nil, - shell: nil, - list: false, - attach: nil, - kill: nil, - snapshot_session: nil, - snapshot_service: nil, - restore_session: nil, - restore_service: nil, - from_snapshot: nil, - snapshot_name: nil, - hot: false, - info_snapshot: nil, - delete_snapshot: nil, - clone_snapshot: nil, - clone_type: nil, - clone_name: nil, - clone_shell: nil, - clone_ports: nil, - audit: false, - tmux: false, - screen: false, - name: nil, - ports: nil, - domains: nil, - type: nil, - bootstrap: nil, - info: nil, - logs: nil, - tail: nil, - sleep: nil, - wake: nil, - destroy: nil, - resize: nil, - execute: nil, - dump_bootstrap: nil, - dump_file: nil, - extend: false, - bootstrap_file: nil, - exec_shell: nil, - env_file: nil, - env_action: nil, - env_target: nil + EXT_MAP = { + 'py' => 'python', 'rb' => 'ruby', 'js' => 'javascript', 'ts' => 'typescript', + 'go' => 'go', 'rs' => 'rust', 'java' => 'java', 'cs' => 'csharp', + 'cpp' => 'cpp', 'c' => 'c', 'h' => 'c', 'sh' => 'bash', 'pl' => 'perl', + 'php' => 'php', 'lua' => 'lua', 'rb' => 'ruby', 'jl' => 'julia', + 'r' => 'r', 'scala' => 'scala', 'kt' => 'kotlin', 'swift' => 'swift', + 'cr' => 'crystal', 'zig' => 'zig', 'nim' => 'nim', 'd' => 'd' } - # Manual argument parsing - i = 0 - while i < ARGV.length - arg = ARGV[i] - - case arg - when 'session', 'service', 'key', 'snapshot' - options[:command] = arg - when '-e' - i += 1 - options[:env] << ARGV[i] - when '-f' - i += 1 - options[:files] << ARGV[i] - when '-a' - options[:artifacts] = true - when '-o' - i += 1 - options[:output_dir] = ARGV[i] - when '-n' - i += 1 - options[:network] = ARGV[i] - when '-v' - i += 1 - options[:vcpu] = ARGV[i].to_i - when '-k' - i += 1 - options[:api_key] = ARGV[i] - when '-s', '--shell' - i += 1 - # For session command, this is shell type. For execute, it's inline exec language. - if options[:command] == 'session' - options[:shell] = ARGV[i] - else - options[:exec_shell] = ARGV[i] - end - when '-l', '--list' - options[:list] = true - when '--attach' - i += 1 - options[:attach] = ARGV[i] - when '--kill' - i += 1 - options[:kill] = ARGV[i] - when '--audit' - options[:audit] = true - when '--tmux' - options[:tmux] = true - when '--screen' - options[:screen] = true - when '--name' - i += 1 - options[:name] = ARGV[i] - when '--ports' - i += 1 - options[:ports] = ARGV[i] - when '--domains' - i += 1 - options[:domains] = ARGV[i] - when '--type' - i += 1 - options[:type] = ARGV[i] - when '--bootstrap' - i += 1 - options[:bootstrap] = ARGV[i] - when '--bootstrap-file' - i += 1 - options[:bootstrap_file] = ARGV[i] - when '--env-file' - i += 1 - options[:env_file] = ARGV[i] - when 'env' - # Handle "service env " subcommand - if options[:command] == 'service' - i += 1 - options[:env_action] = ARGV[i] if i < ARGV.length - i += 1 - if i < ARGV.length && !ARGV[i].start_with?('-') - options[:env_target] = ARGV[i] - else - i -= 1 # back up if next arg is a flag - end - end - when '--info' - i += 1 - options[:info] = ARGV[i] - when '--logs' - i += 1 - options[:logs] = ARGV[i] - when '--tail' - i += 1 - options[:tail] = ARGV[i] - when '--freeze' - i += 1 - options[:sleep] = ARGV[i] - when '--unfreeze' - i += 1 - options[:wake] = ARGV[i] - when '--destroy' - i += 1 - options[:destroy] = ARGV[i] - when '--resize' - i += 1 - options[:resize] = ARGV[i] - when '--execute' - i += 1 - options[:execute] = ARGV[i] - when '--command' - i += 1 - options[:command] = ARGV[i] - when '--dump-bootstrap' - i += 1 - options[:dump_bootstrap] = ARGV[i] - when '--dump-file' - i += 1 - options[:dump_file] = ARGV[i] - when '--snapshot' - i += 1 - if options[:command] == 'session' - options[:snapshot_session] = ARGV[i] - elsif options[:command] == 'service' - options[:snapshot_service] = ARGV[i] - end - when '--restore' - i += 1 - if options[:command] == 'session' - options[:restore_session] = ARGV[i] - elsif options[:command] == 'service' - options[:restore_service] = ARGV[i] - end - when '--from' - i += 1 - options[:from_snapshot] = ARGV[i] - when '--snapshot-name' - i += 1 - options[:snapshot_name] = ARGV[i] - when '--hot' - options[:hot] = true - when '--info' - i += 1 - if options[:command] == 'snapshot' - options[:info_snapshot] = ARGV[i] - else - options[:info] = ARGV[i] - end - when '--delete' - i += 1 - options[:delete_snapshot] = ARGV[i] - when '--clone' - i += 1 - options[:clone_snapshot] = ARGV[i] - when '--type' - i += 1 - if options[:clone_snapshot] - options[:clone_type] = ARGV[i] - else - options[:type] = ARGV[i] - end - when '--shell' - i += 1 - if options[:clone_snapshot] - options[:clone_shell] = ARGV[i] - else - options[:shell] = ARGV[i] - end - when '--extend' - options[:extend] = true - else - if arg.start_with?('-') - warn "#{RED}Unknown option: #{arg}#{RESET}" - exit 1 - else - options[:source_file] = arg - end - end - - i += 1 + def self.detect_language(filename) + ext = File.extname(filename).sub(/^\./, '') + EXT_MAP[ext] || raise(ArgumentError, "Unknown file type: #{filename}") end - case options[:command] - when 'session' - cmd_session(options) - when 'service' - # Check for "service env" subcommand - if options[:env_action] - keys = get_api_keys(options[:api_key]) - cmd_service_env(options[:env_action], options[:env_target], options[:env], options[:env_file], keys) - else - cmd_service(options) - end - when 'snapshot' - cmd_snapshot(options) - when 'key' - cmd_key(options) - else - if options[:source_file] - cmd_execute(options) - else - puts <<~HELP - Unsandbox CLI - Execute code in secure sandboxes + def self.image(code, format = 'png', opts = {}) + body = { 'code' => code, 'format' => format } + _api_request('POST', '/image', body, opts[:public_key], opts[:secret_key]) + end - Usage: - #{$PROGRAM_NAME} [options] - #{$PROGRAM_NAME} session [options] - #{$PROGRAM_NAME} service [options] - #{$PROGRAM_NAME} key [options] + # ======================================================================== + # CLI + # ======================================================================== - 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 - - 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 (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: - -k KEY API key (or use UNSANDBOX_API_KEY env var) - --extend Validate key and open browser to extend - HELP + def self.cli_main + case ARGV[0] + when 'session' + puts "session not yet supported" exit 1 + when 'service' + puts "service not yet supported" + exit 1 + else + # Execute code file + if ARGV.empty? + puts "Usage: ruby un.rb | ruby un.rb -s ''" + exit 1 + end + + if ARGV[0] == '-s' + language = ARGV[1] + code = ARGV[2] + result = execute(language, code) + else + file = ARGV[0] + result = run(file) + end + + puts result['stdout'] if result['stdout'] + STDERR.puts result['stderr'] if result['stderr'] + exit(result['exit_code'] || 0) end end end -main if __FILE__ == $PROGRAM_NAME +# Run CLI if called directly +if __FILE__ == $0 + begin + Un.cli_main + rescue Un::UnsandboxError => e + STDERR.puts "Error: #{e.message}" + exit 1 + rescue StandardError => e + STDERR.puts "Error: #{e.message}" + exit 1 + end +end diff --git a/un.sh b/un.sh index 3e31a04..4e21803 100644 --- a/un.sh +++ b/un.sh @@ -1,1491 +1,176 @@ +#!/bin/bash # 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. -# +# This is free public domain software for the public good of a permacomputer. # 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 - -#!/usr/bin/env bash -set -euo pipefail - -# un.sh - Unsandbox CLI Client (Bash Implementation) # -# 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 -# -# Usage: -# un.sh [options] -# un.sh session [options] -# un.sh service [options] -# -# Requires: UNSANDBOX_API_KEY environment variable, jq, curl +# unsandbox SDK for Bash - Execute code in secure sandboxes +# https://unsandbox.com | https://api.unsandbox.com/openapi 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" -# Extension to language mapping -detect_language() { - local filename="$1" - local ext="${filename##*.}" - ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]') - - case "$ext" in - py) echo "python" ;; - js) echo "javascript" ;; - ts) echo "typescript" ;; - rb) echo "ruby" ;; - php) echo "php" ;; - pl) echo "perl" ;; - lua) echo "lua" ;; - sh) echo "bash" ;; - go) echo "go" ;; - rs) echo "rust" ;; - c) echo "c" ;; - cpp|cc|cxx) echo "cpp" ;; - java) echo "java" ;; - kt) echo "kotlin" ;; - cs) echo "csharp" ;; - fs) echo "fsharp" ;; - hs) echo "haskell" ;; - ml) echo "ocaml" ;; - clj) echo "clojure" ;; - scm) echo "scheme" ;; - lisp) echo "commonlisp" ;; - erl) echo "erlang" ;; - ex|exs) echo "elixir" ;; - jl) echo "julia" ;; - r|R) echo "r" ;; - cr) echo "crystal" ;; - d) echo "d" ;; - nim) echo "nim" ;; - zig) echo "zig" ;; - v) echo "v" ;; - dart) echo "dart" ;; - groovy) echo "groovy" ;; - scala) echo "scala" ;; - f90|f95) echo "fortran" ;; - cob) echo "cobol" ;; - pro) echo "prolog" ;; - forth|4th) echo "forth" ;; - tcl) echo "tcl" ;; - raku) echo "raku" ;; - m) echo "objc" ;; - *) - # Try shebang - if [[ -f "$filename" ]]; then - local first_line=$(head -n1 "$filename") - if [[ "$first_line" =~ ^#! ]]; then - [[ "$first_line" =~ python ]] && echo "python" && return - [[ "$first_line" =~ node ]] && echo "javascript" && return - [[ "$first_line" =~ ruby ]] && echo "ruby" && return - [[ "$first_line" =~ perl ]] && echo "perl" && return - [[ "$first_line" =~ (bash|/sh) ]] && echo "bash" && return - [[ "$first_line" =~ lua ]] && echo "lua" && return - [[ "$first_line" =~ php ]] && echo "php" && return - fi - fi - echo -e "${RED}Error: Cannot detect language for $filename${RESET}" >&2 - exit 1 - ;; - esac +# Credential loading +load_accounts_csv() { + local path="${1:-$HOME/.unsandbox/accounts.csv}" + [ -f "$path" ] || return 1 + head -1 "$path" } +get_credentials() { + # Tier 1: Arguments + [ -n "$PUBLIC_KEY" ] && [ -n "$SECRET_KEY" ] && echo "$PUBLIC_KEY:$SECRET_KEY" && return + + # Tier 2: Environment + [ -n "$UNSANDBOX_PUBLIC_KEY" ] && [ -n "$UNSANDBOX_SECRET_KEY" ] && \ + echo "$UNSANDBOX_PUBLIC_KEY:$UNSANDBOX_SECRET_KEY" && return + + # Tier 3: Home directory + local creds=$(load_accounts_csv "$HOME/.unsandbox/accounts.csv") + [ -n "$creds" ] && echo "$creds" && return + + # Tier 4: Local directory + creds=$(load_accounts_csv "./accounts.csv") + [ -n "$creds" ] && echo "$creds" && return + + echo "No credentials found" >&2 + exit 1 +} + +# HMAC signature +sign_request() { + local secret="$1" + local timestamp="$2" + local method="$3" + local endpoint="$4" + local body="$5" + + local message="$timestamp:$method:$endpoint:$body" + echo -n "$message" | openssl dgst -sha256 -hmac "$secret" -hex | cut -d' ' -f2 +} + +# API request api_request() { - local endpoint="$1" - local method="${2:-GET}" - local data="${3:-}" - local public_key="${4:-${UNSANDBOX_PUBLIC_KEY:-}}" - local secret_key="${5:-${UNSANDBOX_SECRET_KEY:-}}" + local method="$1" + local endpoint="$2" + local body="$3" - # Fallback to old UNSANDBOX_API_KEY for backwards compat - if [[ -z "$public_key" ]] && [[ -n "${UNSANDBOX_API_KEY:-}" ]]; then - public_key="${UNSANDBOX_API_KEY}" - secret_key="" - fi + local creds=$(get_credentials) + local pk=$(echo "$creds" | cut -d: -f1) + local sk=$(echo "$creds" | cut -d: -f2) - if [[ -z "$public_key" ]]; then - echo -e "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}" >&2 - exit 1 - fi - - local url="${API_BASE}${endpoint}" local timestamp=$(date +%s) - local body="${data:-}" + local body_str="${body:-{}}" + local signature=$(sign_request "$sk" "$timestamp" "$method" "$endpoint" "$body_str") - # Build HMAC signature: timestamp:METHOD:path:body - local sig_input="${timestamp}:${method}:${endpoint}:${body}" - local signature="" - - if [[ -n "$secret_key" ]]; then - signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //') - fi - - if [[ -n "$data" ]]; then - local response - if [[ -n "$signature" ]]; then - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: application/json" \ - -d "$data" 2>&1) - else - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "Content-Type: application/json" \ - -d "$data" 2>&1) - fi - else - local response - if [[ -n "$signature" ]]; then - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: application/json" 2>&1) - else - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "Content-Type: application/json" 2>&1) - fi - fi - - local http_code=$(echo "$response" | tail -n1) - local body=$(echo "$response" | head -n-1) - - if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then - if [[ "$http_code" == "401" ]] && echo "$body" | grep -qi "timestamp"; then - echo -e "${RED}Error: Request timestamp expired (must be within 5 minutes of server time)${RESET}" >&2 - echo -e "${YELLOW}Your computer's clock may have drifted.${RESET}" >&2 - echo -e "${YELLOW}Check your system time and sync with NTP if needed:${RESET}" >&2 - echo -e " Linux: sudo ntpdate -s time.nist.gov" >&2 - echo -e " macOS: sudo sntp -sS time.apple.com" >&2 - echo -e " Windows: w32tm /resync" >&2 - else - echo -e "${RED}Error: HTTP $http_code - $body${RESET}" >&2 - fi - exit 1 - fi - - echo "$body" + curl -s -X "$method" "$API_BASE$endpoint" \ + -H "Authorization: Bearer $pk" \ + -H "X-Timestamp: $timestamp" \ + -H "X-Signature: $signature" \ + -H "Content-Type: application/json" \ + -d "$body_str" } -api_request_text() { - local endpoint="$1" - local method="${2:-PUT}" - local body="${3:-}" - local public_key="${4:-${UNSANDBOX_PUBLIC_KEY:-}}" - local secret_key="${5:-${UNSANDBOX_SECRET_KEY:-}}" +# Languages with cache +languages() { + local cache_path="$HOME/.unsandbox/languages.json" + local cache_ttl=3600 - # Fallback to old UNSANDBOX_API_KEY for backwards compat - if [[ -z "$public_key" ]] && [[ -n "${UNSANDBOX_API_KEY:-}" ]]; then - public_key="${UNSANDBOX_API_KEY}" - secret_key="" + if [ -f "$cache_path" ]; then + local age=$(($(date +%s) - $(stat -f%m "$cache_path" 2>/dev/null || stat -c%Y "$cache_path" 2>/dev/null || echo 0))) + [ "$age" -lt "$cache_ttl" ] && cat "$cache_path" && return fi - if [[ -z "$public_key" ]]; then - echo -e "${RED}Error: UNSANDBOX_PUBLIC_KEY or UNSANDBOX_API_KEY not set${RESET}" >&2 - return 1 - fi - - local url="${API_BASE}${endpoint}" - local timestamp=$(date +%s) - - # Build HMAC signature: timestamp:METHOD:path:body - local sig_input="${timestamp}:${method}:${endpoint}:${body}" - local signature="" - - if [[ -n "$secret_key" ]]; then - signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //') - fi - - local response - if [[ -n "$signature" ]]; then - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: text/plain" \ - -d "$body" 2>&1) - else - response=$(curl -s -w "\n%{http_code}" -X "$method" "$url" \ - -H "Authorization: Bearer $public_key" \ - -H "Content-Type: text/plain" \ - -d "$body" 2>&1) - fi - - local http_code=$(echo "$response" | tail -n1) - local resp_body=$(echo "$response" | head -n-1) - - if [[ "$http_code" -lt 200 || "$http_code" -ge 300 ]]; then - echo -e "${RED}Error: HTTP $http_code - $resp_body${RESET}" >&2 - return 1 - fi - - echo "$resp_body" + local result=$(api_request "GET" "/languages" "") + mkdir -p "$HOME/.unsandbox" + echo "$result" | jq '.languages' > "$cache_path" + echo "$result" | jq '.languages' } -# ============================================================================ -# Environment Secrets Vault Functions -# ============================================================================ +# Execute functions +execute() { + local language="$1" + local code="$2" -MAX_ENV_CONTENT_SIZE=65536 # 64KB max env vault size - -service_env_status() { - local service_id="$1" - local api_key="${2:-${UNSANDBOX_API_KEY:-}}" - - local result=$(api_request "/services/$service_id/env" "GET" "" "$api_key") - local has_vault=$(echo "$result" | jq -r '.has_vault // false') - - if [[ "$has_vault" != "true" ]]; then - echo "Vault exists: no" - echo "Variable count: 0" - else - echo "Vault exists: yes" - local count=$(echo "$result" | jq -r '.count // 0') - echo "Variable count: $count" - local updated_at=$(echo "$result" | jq -r '.updated_at // ""') - if [[ -n "$updated_at" ]] && [[ "$updated_at" != "null" ]]; then - local dt=$(date -d "@$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -r "$updated_at" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "$updated_at") - echo "Last updated: $dt" - fi - fi + local body=$(cat <&2 - return 1 - fi - - local content_size=${#env_content} - if [[ $content_size -gt $MAX_ENV_CONTENT_SIZE ]]; then - echo -e "${RED}Error: Environment content too large (max $MAX_ENV_CONTENT_SIZE bytes)${RESET}" >&2 - return 1 - fi - - local result - result=$(api_request_text "/services/$service_id/env" "PUT" "$env_content" "$api_key") - if [[ $? -ne 0 ]]; then - return 1 - fi - - local count=$(echo "$result" | jq -r '.count // -1') - if [[ "$count" != "-1" ]]; then - local plural="s" - [[ "$count" == "1" ]] && plural="" - echo -e "${GREEN}Environment vault updated: $count variable$plural${RESET}" - else - echo -e "${GREEN}Environment vault updated${RESET}" - fi - - local message=$(echo "$result" | jq -r '.message // ""') - [[ -n "$message" ]] && echo "$message" - - return 0 + local body=$(cat < /dev/null - echo -e "${GREEN}Environment vault deleted${RESET}" +# Job management +get_job() { + local job_id="$1" + api_request "GET" "/jobs/$job_id" "" } -read_env_file() { - local filepath="$1" - if [[ ! -f "$filepath" ]]; then - echo -e "${RED}Error: Env file not found: $filepath${RESET}" >&2 - exit 1 - fi - cat "$filepath" -} +wait_job() { + local job_id="$1" + local delays=(300 450 700 900 650 1600 2000) -build_env_content() { - local env_file="$1" - shift - local -a env_vars=("$@") - local parts="" + for i in $(seq 0 119); do + local job=$(get_job "$job_id") + local status=$(echo "$job" | jq -r '.status') - # Read from env file first - if [[ -n "$env_file" ]]; then - parts+=$(read_env_file "$env_file") - fi + [ "$status" = "completed" ] && echo "$job" && return 0 + [ "$status" = "failed" ] && exit 1 - # Add -e flags (these override/append to file) - for e in "${env_vars[@]}"; do - if [[ "$e" == *"="* ]]; then - [[ -n "$parts" ]] && parts+=$'\n' - parts+="$e" - fi + local delay=${delays[$((i % 7))]} + sleep $((delay / 1000)) done - echo "$parts" + echo "Max polls exceeded" >&2 + exit 1 } -cmd_execute() { - local source_file="" - local -a env_vars=() - local -a input_files=() - local artifacts=false - local output_dir="." - local network="" - local vcpu="" - local api_key="${UNSANDBOX_API_KEY:-}" - local exec_shell="" - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - -s|--shell) - exec_shell="$2" - shift 2 - ;; - -e) - env_vars+=("$2") - shift 2 - ;; - -f) - input_files+=("$2") - shift 2 - ;; - -a) - artifacts=true - shift - ;; - -o) - output_dir="$2" - shift 2 - ;; - -n) - network="$2" - shift 2 - ;; - -v) - vcpu="$2" - shift 2 - ;; - -k) - api_key="$2" - shift 2 - ;; - -*) - echo -e "${RED}Unknown option: $1${RESET}" >&2 - exit 1 - ;; - *) - source_file="$1" - shift - ;; - esac - done - - local code - local language - - # Check for inline mode: -s/--shell specified, or source_file doesn't exist - if [[ -n "$exec_shell" ]]; then - # Inline mode with specified language - code="$source_file" - language="$exec_shell" - elif [[ ! -f "$source_file" ]]; then - # File doesn't exist - treat as inline bash code - code="$source_file" - language="bash" - else - # Normal file execution - code=$(cat "$source_file") - language=$(detect_language "$source_file") - fi - - # Build JSON payload - local payload=$(jq -n \ - --arg lang "$language" \ - --arg code "$code" \ - '{language: $lang, code: $code}') - - # Add environment variables - if [[ ${#env_vars[@]} -gt 0 ]]; then - local env_json="{" - for env_var in "${env_vars[@]}"; do - local key="${env_var%%=*}" - local val="${env_var#*=}" - env_json+="\"$key\":\"$val\"," - done - env_json="${env_json%,}}" - payload=$(echo "$payload" | jq --argjson env "$env_json" '. + {env: $env}') - fi - - # Add input files - if [[ ${#input_files[@]} -gt 0 ]]; then - local files_json="[" - for file in "${input_files[@]}"; do - if [[ ! -f "$file" ]]; then - echo -e "${RED}Error: Input file not found: $file${RESET}" >&2 - exit 1 - fi - local filename=$(basename "$file") - local content_b64=$(base64 -w0 < "$file") - files_json+="{\"filename\":\"$filename\",\"content_base64\":\"$content_b64\"}," - done - files_json="${files_json%,}]" - payload=$(echo "$payload" | jq --argjson files "$files_json" '. + {input_files: $files}') - fi - - # Add options - [[ "$artifacts" == true ]] && payload=$(echo "$payload" | jq '. + {return_artifacts: true}') - [[ -n "$network" ]] && payload=$(echo "$payload" | jq --arg n "$network" '. + {network: $n}') - [[ -n "$vcpu" ]] && payload=$(echo "$payload" | jq --argjson v "$vcpu" '. + {vcpu: $v}') - - # Execute - local result=$(api_request "/execute" "POST" "$payload" "$api_key") - - # Print output - local stdout=$(echo "$result" | jq -r '.stdout // empty') - local stderr=$(echo "$result" | jq -r '.stderr // empty') - [[ -n "$stdout" ]] && echo -e "${BLUE}${stdout}${RESET}" - [[ -n "$stderr" ]] && echo -e "${RED}${stderr}${RESET}" >&2 - - # Save artifacts - if [[ "$artifacts" == true ]]; then - local artifacts_json=$(echo "$result" | jq -r '.artifacts // []') - if [[ "$artifacts_json" != "[]" ]]; then - mkdir -p "$output_dir" - local num_artifacts=$(echo "$artifacts_json" | jq 'length') - for ((i=0; i "$filepath" - chmod 755 "$filepath" - echo -e "${GREEN}Saved: $filepath${RESET}" >&2 - done - fi - fi - - local exit_code=$(echo "$result" | jq -r '.exit_code // 0') - exit "$exit_code" -} - -cmd_session() { - local shell="bash" - local list=false - local attach="" - local kill="" - local audit=false - local tmux=false - local screen=false - local network="" - local vcpu="" - local api_key="${UNSANDBOX_API_KEY:-}" - local -a input_files=() - local snapshot="" - local restore="" - local from_snapshot="" - local snapshot_name="" - local hot=false - - while [[ $# -gt 0 ]]; do - case "$1" in - -s|--shell) - shell="$2" - shift 2 - ;; - -l|--list) - list=true - shift - ;; - --attach) - attach="$2" - shift 2 - ;; - --kill) - kill="$2" - shift 2 - ;; - --audit) - audit=true - shift - ;; - --tmux) - tmux=true - shift - ;; - --screen) - screen=true - shift - ;; - --snapshot) - snapshot="$2" - shift 2 - ;; - --restore) - restore="$2" - shift 2 - ;; - --from) - from_snapshot="$2" - shift 2 - ;; - --snapshot-name) - snapshot_name="$2" - shift 2 - ;; - --hot) - hot=true - shift - ;; - -f) - input_files+=("$2") - shift 2 - ;; - -n) - network="$2" - shift 2 - ;; - -v) - vcpu="$2" - shift 2 - ;; - -k) - api_key="$2" - shift 2 - ;; - -*) - echo -e "${RED}Unknown option: $1${RESET}" >&2 - exit 1 - ;; - *) - shift - ;; - esac - done - - if [[ "$list" == true ]]; then - local result=$(api_request "/sessions" "GET" "" "$api_key") - local sessions=$(echo "$result" | jq -r '.sessions // []') - if [[ "$sessions" == "[]" ]]; then - echo "No active sessions" - else - printf "%-40s %-10s %-10s %s\n" "ID" "Shell" "Status" "Created" - echo "$sessions" | jq -r '.[] | "\(.id // "N/A") \(.shell // "N/A") \(.status // "N/A") \(.created_at // "N/A")"' | \ - while read -r id sh status created; do - printf "%-40s %-10s %-10s %s\n" "$id" "$sh" "$status" "$created" - done - fi - return - fi - - if [[ -n "$kill" ]]; then - api_request "/sessions/$kill" "DELETE" "" "$api_key" > /dev/null - echo -e "${GREEN}Session terminated: $kill${RESET}" - return - fi - - if [[ -n "$snapshot" ]]; then - local payload=$(jq -n --arg name "$snapshot_name" --argjson hot "$hot" '{name: $name, hot: $hot}') - echo -e "${YELLOW}Creating snapshot of session $snapshot...${RESET}" - local result=$(api_request "/sessions/$snapshot/snapshot" "POST" "$payload" "$api_key") - local snapshot_id=$(echo "$result" | jq -r '.id // "N/A"') - echo -e "${GREEN}Snapshot created successfully${RESET}" - echo "Snapshot ID: $snapshot_id" - return - fi - - if [[ -n "$restore" ]]; then - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - echo -e "${YELLOW}Restoring from snapshot $restore...${RESET}" - local result=$(api_request "/snapshots/$restore/restore" "POST" "{}" "$api_key") - echo -e "${GREEN}Session restored from snapshot${RESET}" - local new_id=$(echo "$result" | jq -r '.session_id // empty') - [[ -n "$new_id" ]] && echo "New session ID: $new_id" - return - fi - - if [[ -n "$attach" ]]; then - echo -e "${YELLOW}Attaching to session $attach...${RESET}" - echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}" - return - fi - - # Create session - local payload=$(jq -n --arg sh "$shell" '{shell: $sh}') - [[ -n "$network" ]] && payload=$(echo "$payload" | jq --arg n "$network" '. + {network: $n}') - [[ -n "$vcpu" ]] && payload=$(echo "$payload" | jq --argjson v "$vcpu" '. + {vcpu: $v}') - [[ "$tmux" == true ]] && payload=$(echo "$payload" | jq '. + {persistence: "tmux"}') - [[ "$screen" == true ]] && payload=$(echo "$payload" | jq '. + {persistence: "screen"}') - [[ "$audit" == true ]] && payload=$(echo "$payload" | jq '. + {audit: true}') - - # Add input files - if [[ ${#input_files[@]} -gt 0 ]]; then - local files_json="[" - for file in "${input_files[@]}"; do - if [[ ! -f "$file" ]]; then - echo -e "${RED}Error: Input file not found: $file${RESET}" >&2 - exit 1 - fi - local filename=$(basename "$file") - local content_b64=$(base64 -w0 < "$file") - files_json+="{\"filename\":\"$filename\",\"content_base64\":\"$content_b64\"}," - done - files_json="${files_json%,}]" - payload=$(echo "$payload" | jq --argjson files "$files_json" '. + {input_files: $files}') - fi - - echo -e "${YELLOW}Creating session...${RESET}" - local result=$(api_request "/sessions" "POST" "$payload" "$api_key") - local session_id=$(echo "$result" | jq -r '.id // "N/A"') - echo -e "${GREEN}Session created: $session_id${RESET}" - echo -e "${YELLOW}(Interactive sessions require WebSocket - use un2 for full support)${RESET}" -} - -cmd_service_env() { - local action="$1" - local target="$2" - shift 2 - - local api_key="${UNSANDBOX_API_KEY:-}" - local env_file="" - local -a env_vars=() - - # Parse remaining args for -e and --env-file - while [[ $# -gt 0 ]]; do - case "$1" in - -e) - env_vars+=("$2") - shift 2 - ;; - --env-file) - env_file="$2" - shift 2 - ;; - -k) - api_key="$2" - shift 2 - ;; - *) - shift - ;; - esac - done - - if [[ -z "$action" ]]; then - echo -e "${RED}Error: env action required (status, set, export, delete)${RESET}" >&2 - exit 1 - fi - - if [[ -z "$target" ]]; then - echo -e "${RED}Error: Service ID required for env command${RESET}" >&2 - exit 1 - fi - - case "$action" in - status) - service_env_status "$target" "$api_key" - ;; - set) - local env_content=$(build_env_content "$env_file" "${env_vars[@]}") - if [[ -z "$env_content" ]]; then - echo -e "${RED}Error: No env content provided. Use -e KEY=VAL, --env-file, or pipe to stdin${RESET}" >&2 - exit 1 - fi - service_env_set "$target" "$env_content" "$api_key" - ;; - export) - service_env_export "$target" "$api_key" - ;; - delete) - service_env_delete "$target" "$api_key" - ;; - *) - echo -e "${RED}Error: Unknown env action '$action'. Use: status, set, export, delete${RESET}" >&2 - exit 1 - ;; +# Utilities +detect_language() { + local file="$1" + case "$file" in + *.py) echo "python" ;; + *.sh) echo "bash" ;; + *.rb) echo "ruby" ;; + *.js) echo "javascript" ;; + *) echo "Unknown file type" >&2; exit 1 ;; esac } -cmd_service() { - local name="" - local ports="" - local domains="" - local service_type="" - local bootstrap="" - local bootstrap_file="" - local list=false - local info="" - local logs="" - local tail="" - local sleep="" - local wake="" - local destroy="" - local resize="" - local execute="" - local command="" - local network="" - local vcpu="" - local api_key="${UNSANDBOX_API_KEY:-}" - local -a input_files=() - local -a env_vars=() - local env_file="" - local snapshot="" - local restore="" - local from_snapshot="" - local snapshot_name="" - local hot=false - - while [[ $# -gt 0 ]]; do - case "$1" in - -e) - env_vars+=("$2") - shift 2 - ;; - --env-file) - env_file="$2" - shift 2 - ;; - --name) - name="$2" - shift 2 - ;; - --ports) - ports="$2" - shift 2 - ;; - --domains) - domains="$2" - shift 2 - ;; - --type) - service_type="$2" - shift 2 - ;; - --bootstrap) - bootstrap="$2" - shift 2 - ;; - --bootstrap-file) - bootstrap_file="$2" - shift 2 - ;; - -f) - input_files+=("$2") - shift 2 - ;; - -l|--list) - list=true - shift - ;; - --info) - info="$2" - shift 2 - ;; - --logs) - logs="$2" - shift 2 - ;; - --tail) - tail="$2" - shift 2 - ;; - --freeze) - sleep="$2" - shift 2 - ;; - --unfreeze) - wake="$2" - shift 2 - ;; - --destroy) - destroy="$2" - shift 2 - ;; - --resize) - resize="$2" - shift 2 - ;; - --execute) - execute="$2" - shift 2 - ;; - --command) - command="$2" - shift 2 - ;; - --dump-bootstrap) - dump_bootstrap="$2" - shift 2 - ;; - --dump-file) - dump_file="$2" - shift 2 - ;; - --snapshot) - snapshot="$2" - shift 2 - ;; - --restore) - restore="$2" - shift 2 - ;; - --from) - from_snapshot="$2" - shift 2 - ;; - --snapshot-name) - snapshot_name="$2" - shift 2 - ;; - --hot) - hot=true - shift - ;; - -n) - network="$2" - shift 2 - ;; - -v) - vcpu="$2" - shift 2 - ;; - -k) - api_key="$2" - shift 2 - ;; - -*) - echo -e "${RED}Unknown option: $1${RESET}" >&2 - exit 1 - ;; - *) - shift - ;; - esac - done - - if [[ "$list" == true ]]; then - local result=$(api_request "/services" "GET" "" "$api_key") - local services=$(echo "$result" | jq -r '.services // []') - if [[ "$services" == "[]" ]]; then - echo "No services" - else - printf "%-20s %-15s %-10s %-15s %s\n" "ID" "Name" "Status" "Ports" "Domains" - echo "$services" | jq -r '.[] | "\(.id // "N/A") \(.name // "N/A") \(.status // "N/A") \((.ports // []) | join(",")) \((.domains // []) | join(","))"' | \ - while read -r id name status ports domains; do - printf "%-20s %-15s %-10s %-15s %s\n" "$id" "$name" "$status" "$ports" "$domains" - done - fi - return - fi - - if [[ -n "$snapshot" ]]; then - local payload=$(jq -n --arg name "$snapshot_name" --argjson hot "$hot" '{name: $name, hot: $hot}') - echo -e "${YELLOW}Creating snapshot of service $snapshot...${RESET}" - local result=$(api_request "/services/$snapshot/snapshot" "POST" "$payload" "$api_key") - local snapshot_id=$(echo "$result" | jq -r '.id // "N/A"') - echo -e "${GREEN}Snapshot created successfully${RESET}" - echo "Snapshot ID: $snapshot_id" - return - fi - - if [[ -n "$restore" ]]; then - # --restore takes snapshot ID directly, calls /snapshots/:id/restore - echo -e "${YELLOW}Restoring from snapshot $restore...${RESET}" - local result=$(api_request "/snapshots/$restore/restore" "POST" "{}" "$api_key") - echo -e "${GREEN}Service restored from snapshot${RESET}" - local new_id=$(echo "$result" | jq -r '.service_id // empty') - [[ -n "$new_id" ]] && echo "New service ID: $new_id" - return - fi - - if [[ -n "$info" ]]; then - local result=$(api_request "/services/$info" "GET" "" "$api_key") - echo "$result" | jq '.' - return - fi - - if [[ -n "$logs" ]]; then - local result=$(api_request "/services/$logs/logs" "GET" "" "$api_key") - echo "$result" | jq -r '.logs // ""' - return - fi - - if [[ -n "$tail" ]]; then - local result=$(api_request "/services/$tail/logs?lines=9000" "GET" "" "$api_key") - echo "$result" | jq -r '.logs // ""' - return - fi - - if [[ -n "$sleep" ]]; then - api_request "/services/$sleep/freeze" "POST" "" "$api_key" > /dev/null - echo -e "${GREEN}Service frozen: $sleep${RESET}" - return - fi - - if [[ -n "$wake" ]]; then - api_request "/services/$wake/unfreeze" "POST" "" "$api_key" > /dev/null - echo -e "${GREEN}Service unfreezing: $wake${RESET}" - return - fi - - if [[ -n "$destroy" ]]; then - api_request "/services/$destroy" "DELETE" "" "$api_key" > /dev/null - echo -e "${GREEN}Service destroyed: $destroy${RESET}" - return - fi - - if [[ -n "$resize" ]]; then - if [[ -z "$vcpu" ]]; then - echo -e "${RED}Error: --vcpu is required with --resize${RESET}" >&2 - exit 1 - fi - local payload=$(jq -n --argjson v "$vcpu" '{vcpu: $v}') - api_request "/services/$resize" "PATCH" "$payload" "$api_key" > /dev/null - local ram=$((vcpu * 2)) - echo -e "${GREEN}Service resized to $vcpu vCPU, $ram GB RAM${RESET}" - return - fi - - if [[ -n "$execute" ]]; then - local payload=$(jq -n --arg cmd "$command" '{command: $cmd}') - local result=$(api_request "/services/$execute/execute" "POST" "$payload" "$api_key") - local stdout=$(echo "$result" | jq -r '.stdout // empty') - local stderr=$(echo "$result" | jq -r '.stderr // empty') - [[ -n "$stdout" ]] && echo -e "${BLUE}${stdout}${RESET}" - [[ -n "$stderr" ]] && echo -e "${RED}${stderr}${RESET}" >&2 - return - fi - - if [[ -n "$dump_bootstrap" ]]; then - echo "Fetching bootstrap script from $dump_bootstrap..." >&2 - local payload=$(jq -n '{command: "cat /tmp/bootstrap.sh"}') - local result=$(api_request "/services/$dump_bootstrap/execute" "POST" "$payload" "$api_key") - local bootstrap=$(echo "$result" | jq -r '.stdout // empty') - - if [[ -n "$bootstrap" ]]; then - if [[ -n "$dump_file" ]]; then - # Write to file - echo "$bootstrap" > "$dump_file" - chmod 755 "$dump_file" - echo "Bootstrap saved to $dump_file" - else - # Print to stdout - echo -n "$bootstrap" - fi - else - echo -e "${RED}Error: Failed to fetch bootstrap (service not running or no bootstrap file)${RESET}" >&2 - exit 1 - fi - return - fi - - if [[ -n "$name" ]]; then - local payload=$(jq -n --arg n "$name" '{name: $n}') - - if [[ -n "$ports" ]]; then - local ports_json="[$(echo "$ports" | sed 's/,/,/g')]" - payload=$(echo "$payload" | jq --argjson p "$ports_json" '. + {ports: $p}') - fi - - if [[ -n "$domains" ]]; then - local domains_json="[\"$(echo "$domains" | sed 's/,/","/g')\"]" - payload=$(echo "$payload" | jq --argjson d "$domains_json" '. + {domains: $d}') - fi - - if [[ -n "$service_type" ]]; then - payload=$(echo "$payload" | jq --arg t "$service_type" '. + {service_type: $t}') - fi - - if [[ -n "$bootstrap" ]]; then - payload=$(echo "$payload" | jq --arg b "$bootstrap" '. + {bootstrap: $b}') - fi - - if [[ -n "$bootstrap_file" ]]; then - if [[ ! -f "$bootstrap_file" ]]; then - echo -e "${RED}Error: Bootstrap file not found: $bootstrap_file${RESET}" >&2 - return 1 - fi - local file_content=$(cat "$bootstrap_file") - payload=$(echo "$payload" | jq --arg b "$file_content" '. + {bootstrap_content: $b}') - fi - - # Add input files - if [[ ${#input_files[@]} -gt 0 ]]; then - local files_json="[" - for file in "${input_files[@]}"; do - if [[ ! -f "$file" ]]; then - echo -e "${RED}Error: Input file not found: $file${RESET}" >&2 - exit 1 - fi - local filename=$(basename "$file") - local content_b64=$(base64 -w0 < "$file") - files_json+="{\"filename\":\"$filename\",\"content_base64\":\"$content_b64\"}," - done - files_json="${files_json%,}]" - payload=$(echo "$payload" | jq --argjson files "$files_json" '. + {input_files: $files}') - fi - - [[ -n "$network" ]] && payload=$(echo "$payload" | jq --arg n "$network" '. + {network: $n}') - [[ -n "$vcpu" ]] && payload=$(echo "$payload" | jq --argjson v "$vcpu" '. + {vcpu: $v}') - - local result=$(api_request "/services" "POST" "$payload" "$api_key") - local service_id=$(echo "$result" | jq -r '.id // "N/A"') - local service_name=$(echo "$result" | jq -r '.name // "N/A"') - local service_url=$(echo "$result" | jq -r '.url // ""') - - echo -e "${GREEN}Service created: $service_id${RESET}" - echo "Name: $service_name" - [[ -n "$service_url" ]] && echo "URL: $service_url" - - # Set environment vault if -e or --env-file provided - if [[ "$service_id" != "N/A" ]]; then - local env_content=$(build_env_content "$env_file" "${env_vars[@]}") - if [[ -n "$env_content" ]]; then - echo -e "${YELLOW}Setting environment vault...${RESET}" >&2 - if ! service_env_set "$service_id" "$env_content" "$api_key"; then - echo -e "${YELLOW}Warning: Failed to set environment vault${RESET}" >&2 - fi - fi - fi - return - fi - - echo -e "${RED}Error: Specify --name to create a service, or use --list, --info, etc.${RESET}" >&2 - exit 1 -} - -cmd_snapshot() { - local api_key="${UNSANDBOX_API_KEY:-}" - local list=false - local info="" - local delete="" - local clone="" - local clone_type="" - local clone_name="" - local clone_shell="" - local clone_ports="" - - while [[ $# -gt 0 ]]; do - case "$1" in - -l|--list) - list=true - shift - ;; - --info) - info="$2" - shift 2 - ;; - --delete) - delete="$2" - shift 2 - ;; - --clone) - clone="$2" - shift 2 - ;; - --type) - clone_type="$2" - shift 2 - ;; - --name) - clone_name="$2" - shift 2 - ;; - --shell) - clone_shell="$2" - shift 2 - ;; - --ports) - clone_ports="$2" - shift 2 - ;; - -k) - api_key="$2" - shift 2 - ;; - -*) - echo -e "${RED}Unknown option: $1${RESET}" >&2 - exit 1 - ;; - *) - shift - ;; - esac - done - - if [[ "$list" == true ]]; then - local result=$(api_request "/snapshots" "GET" "" "$api_key") - echo "$result" | jq '.' - return - fi - - if [[ -n "$info" ]]; then - local result=$(api_request "/snapshots/$info" "GET" "" "$api_key") - echo "$result" | jq '.' - return - fi - - if [[ -n "$delete" ]]; then - api_request "/snapshots/$delete" "DELETE" "" "$api_key" > /dev/null - echo -e "${GREEN}Snapshot deleted successfully${RESET}" - return - fi - - if [[ -n "$clone" ]]; then - if [[ -z "$clone_type" ]]; then - echo -e "${RED}Error: --type required with --clone (session or service)${RESET}" >&2 - exit 1 - fi - - local payload=$(jq -n --arg type "$clone_type" '{type: $type}') - [[ -n "$clone_name" ]] && payload=$(echo "$payload" | jq --arg n "$clone_name" '. + {name: $n}') - [[ -n "$clone_shell" ]] && payload=$(echo "$payload" | jq --arg s "$clone_shell" '. + {shell: $s}') - if [[ -n "$clone_ports" ]]; then - local ports_json="[$(echo "$clone_ports" | sed 's/,/,/g')]" - payload=$(echo "$payload" | jq --argjson p "$ports_json" '. + {ports: $p}') - fi - - echo -e "${YELLOW}Cloning snapshot $clone to create new $clone_type...${RESET}" - local result=$(api_request "/snapshots/$clone/clone" "POST" "$payload" "$api_key") - - if [[ "$clone_type" == "session" ]]; then - local session_id=$(echo "$result" | jq -r '.session_id // "N/A"') - echo -e "${GREEN}Session created from snapshot${RESET}" - echo "Session ID: $session_id" - else - local service_id=$(echo "$result" | jq -r '.service_id // "N/A"') - echo -e "${GREEN}Service created from snapshot${RESET}" - echo "Service ID: $service_id" - fi - return - fi - - echo -e "${RED}Error: Specify --list, --info, --delete, or --clone${RESET}" >&2 - exit 1 -} - -validate_key() { - local api_key="$1" - local extend_mode="$2" - - if [[ -z "$api_key" ]]; then - echo -e "${RED}Error: API key not provided. Use -k flag or set UNSANDBOX_API_KEY${RESET}" >&2 - exit 1 - fi - - # Get keys - api_key might be public key or legacy format - local public_key="${UNSANDBOX_PUBLIC_KEY:-$api_key}" - local secret_key="${UNSANDBOX_SECRET_KEY:-}" - - # Generate HMAC signature for portal request - local timestamp=$(date +%s) - local endpoint="/keys/validate" - local body="" - local sig_input="${timestamp}:POST:${endpoint}:${body}" - local signature="" - - if [[ -n "$secret_key" ]]; then - signature=$(echo -n "$sig_input" | openssl dgst -sha256 -hmac "$secret_key" | sed 's/^.* //') - fi - - # Call portal validation endpoint - local response - local http_code - if [[ -n "$signature" ]]; then - response=$(curl -s -w "\n%{http_code}" -X POST "${PORTAL_BASE}${endpoint}" \ - -H "Authorization: Bearer $public_key" \ - -H "X-Timestamp: $timestamp" \ - -H "X-Signature: $signature" \ - -H "Content-Type: application/json" 2>&1) - else - response=$(curl -s -w "\n%{http_code}" -X POST "${PORTAL_BASE}${endpoint}" \ - -H "Authorization: Bearer $public_key" \ - -H "Content-Type: application/json" 2>&1) - fi - - http_code=$(echo "$response" | tail -n1) - local body=$(echo "$response" | head -n-1) - - if [[ "$http_code" -eq 200 ]]; then - # Valid key - parse response - if command -v jq &> /dev/null; then - # Use jq for parsing - local valid=$(echo "$body" | jq -r '.valid // false') - local public_key=$(echo "$body" | jq -r '.public_key // "N/A"') - local tier=$(echo "$body" | jq -r '.tier // "N/A"') - local expires_at=$(echo "$body" | jq -r '.expires_at // "N/A"') - local expired=$(echo "$body" | jq -r '.expired // false') - - if [[ "$expired" == "true" ]]; then - echo -e "${RED}Expired${RESET}" - echo "Public Key: $public_key" - echo "Tier: $tier" - echo "Expired: $expires_at" - echo -e "${YELLOW}To renew: Visit ${PORTAL_BASE}/keys/extend${RESET}" - exit 1 - else - echo -e "${GREEN}Valid${RESET}" - echo "Public Key: $public_key" - echo "Tier: $tier" - echo "Expires: $expires_at" - - # If extend mode, open browser - if [[ "$extend_mode" == "true" ]]; then - local extend_url="${PORTAL_BASE}/keys/extend?pk=${public_key}" - echo -e "\n${BLUE}Opening browser to extend key...${RESET}" - - # Detect platform and open browser - if command -v xdg-open &> /dev/null; then - xdg-open "$extend_url" &> /dev/null - elif command -v open &> /dev/null; then - open "$extend_url" &> /dev/null - elif command -v start &> /dev/null; then - start "$extend_url" &> /dev/null - else - echo -e "${YELLOW}Cannot detect browser opener. Visit: $extend_url${RESET}" - fi - fi - fi - else - # Fallback: use grep/sed for parsing (no jq available) - local valid=$(echo "$body" | grep -o '"valid"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*:[[:space:]]*//' | tr -d ' "') - local public_key=$(echo "$body" | grep -o '"public_key"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') - local tier=$(echo "$body" | grep -o '"tier"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') - local expires_at=$(echo "$body" | grep -o '"expires_at"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') - local expired=$(echo "$body" | grep -o '"expired"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*:[[:space:]]*//' | tr -d ' "') - - [[ -z "$public_key" ]] && public_key="N/A" - [[ -z "$tier" ]] && tier="N/A" - [[ -z "$expires_at" ]] && expires_at="N/A" - - if [[ "$expired" == "true" ]]; then - echo -e "${RED}Expired${RESET}" - echo "Public Key: $public_key" - echo "Tier: $tier" - echo "Expired: $expires_at" - echo -e "${YELLOW}To renew: Visit ${PORTAL_BASE}/keys/extend${RESET}" - exit 1 - else - echo -e "${GREEN}Valid${RESET}" - echo "Public Key: $public_key" - echo "Tier: $tier" - echo "Expires: $expires_at" - - # If extend mode, open browser - if [[ "$extend_mode" == "true" ]]; then - local extend_url="${PORTAL_BASE}/keys/extend?pk=${public_key}" - echo -e "\n${BLUE}Opening browser to extend key...${RESET}" - - # Detect platform and open browser - if command -v xdg-open &> /dev/null; then - xdg-open "$extend_url" &> /dev/null - elif command -v open &> /dev/null; then - open "$extend_url" &> /dev/null - elif command -v start &> /dev/null; then - start "$extend_url" &> /dev/null - else - echo -e "${YELLOW}Cannot detect browser opener. Visit: $extend_url${RESET}" - fi - fi - fi - fi - else - # Invalid key or error - if command -v jq &> /dev/null; then - local error=$(echo "$body" | jq -r '.error // "Unknown error"') - echo -e "${RED}Invalid${RESET}" - echo "Error: $error" - else - # Fallback - local error=$(echo "$body" | grep -o '"error"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:[[:space:]]*"//' | tr -d '"') - [[ -z "$error" ]] && error="Unknown error (HTTP $http_code)" - echo -e "${RED}Invalid${RESET}" - echo "Error: $error" - fi - exit 1 - fi -} - -cmd_key() { - local api_key="${UNSANDBOX_API_KEY:-${UNSANDBOX_PUBLIC_KEY:-}}" - local extend=false - - # Parse arguments - while [[ $# -gt 0 ]]; do - case "$1" in - -k) - api_key="$2" - shift 2 - ;; - --extend) - extend=true - shift - ;; - -*) - echo -e "${RED}Unknown option: $1${RESET}" >&2 - exit 1 - ;; - *) - shift - ;; - esac - done - - validate_key "$api_key" "$extend" -} - -# Main -show_help() { - cat < - $0 session [options] - $0 service [options] - $0 snapshot [options] - $0 key [options] - -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 - -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 - --snapshot SESSION_ID Create snapshot of session - --restore SNAPSHOT_ID Restore from snapshot ID - --snapshot-name NAME Optional name for snapshot - --hot Take snapshot without freezing (live snapshot) - -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) - --snapshot SERVICE_ID Create snapshot of service - --restore SNAPSHOT_ID Restore from snapshot ID - --snapshot-name NAME Optional name for snapshot - --hot Take snapshot without freezing (live snapshot) - -Snapshot options: - -l, --list List all snapshots - --info ID Get snapshot details - --delete ID Delete a snapshot - --clone ID Clone snapshot to new session/service (--type required) - --type TYPE Type for clone: session or service - --name NAME Name for cloned session/service - --shell NAME Shell for cloned session - --ports PORTS Ports for cloned service - -Key options: - -k KEY API key to validate - --extend Validate and open browser to extend key -EOF -} - -# Handle help and no args -if [[ $# -eq 0 ]] || [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then - show_help - exit 0 -fi - -# Route to command -if [[ "$1" == "session" ]]; then - shift - cmd_session "$@" -elif [[ "$1" == "service" ]]; then - shift - # Check for env subcommand: service env - if [[ "${1:-}" == "env" ]]; then - shift - cmd_service_env "$@" - else - cmd_service "$@" - fi -elif [[ "$1" == "snapshot" ]]; then - shift - cmd_snapshot "$@" -elif [[ "$1" == "key" ]]; then - shift - cmd_key "$@" +# CLI +if [ $# -gt 0 ]; then + result=$(run "$1") + echo "$result" | jq -r '.stdout // empty' + echo "$result" | jq -r '.stderr // empty' >&2 + exit "$(echo "$result" | jq -r '.exit_code // 0')" else - cmd_execute "$@" + echo "Usage: bash un.sh " >&2 + exit 1 fi