Console Playground

CLI

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

Official OpenAPI Swagger Docs ↗

Quick Start — F#

# Download + setup
curl -O https://git.unturf.com/engineering/unturf/un-inception/-/raw/main/clients/fsharp/sync/src/un.fs
export UNSANDBOX_PUBLIC_KEY="unsb-pk-xxxx-xxxx-xxxx-xxxx"
export UNSANDBOX_SECRET_KEY="unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
# Run code
./un script.fs

Downloads

Install Guide →
Static Binary
Linux x86_64 (5.3MB)
un
F# SDK
un.fs (63.4 KB)
Download

Features

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

Integration Quickstart ⚡

Add unsandbox superpowers to your existing F# app:

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

# Option B: Config file (persistent)
mkdir -p ~/.unsandbox
echo "unsb-pk-xxxx-xxxx-xxxx-xxxx,unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx" > ~/.unsandbox/accounts.csv
3
Hello World
// In your F# app:
open Un

let result = executeCode "fsharp" "printfn \"Hello from F# running on unsandbox!\""
printfn "%s" result.["stdout"]  // Hello from F# running on unsandbox!
Demo cooldown: s
stdout:

                      
JSON Response:

                      
4
Run
dotnet fsi main.fsx
Source Code 📄 (1435 lines)
MD5: f1fde7374d0efcf23030b77f599409e1 SHA256: dc1190b989c518d3459af8f019aa443fcb8be13d62ea16e83ce1a57dcb61bb1a
// 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


// un.fs - Unsandbox CLI Client (F# Implementation)
// Compile: fsharpc un.fs
// Run: mono un.exe [options] <source_file>
// Requires: UNSANDBOX_API_KEY environment variable

open System
open System.IO
open System.Net
open System.Text
open System.Security.Cryptography

let apiBase = "https://api.unsandbox.com"
let portalBase = "https://unsandbox.com"
let languagesCacheTtl = 3600 // 1 hour in seconds

let blue = "\x1B[34m"
let red = "\x1B[31m"
let green = "\x1B[32m"
let yellow = "\x1B[33m"
let reset = "\x1B[0m"

let extMap =
    Map.ofList [
        (".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")
    ]

type Args = {
    mutable Command: string option
    mutable SourceFile: string option
    mutable ApiKey: string option
    mutable Network: string option
    mutable Vcpu: int
    Env: ResizeArray<string>
    Files: ResizeArray<string>
    mutable Artifacts: bool
    mutable OutputDir: string option
    mutable SessionList: bool
    mutable SessionShell: string option
    mutable SessionKill: string option
    mutable SessionSnapshot: string option
    mutable SessionRestore: string option
    mutable SessionFrom: string option
    mutable SessionSnapshotName: string option
    mutable SessionHot: bool
    mutable ServiceList: bool
    mutable LanguagesJson: bool
    mutable ServiceName: string option
    mutable ServicePorts: string option
    mutable ServiceType: string option
    mutable ServiceBootstrap: string option
    mutable ServiceBootstrapFile: string option
    mutable ServiceInfo: string option
    mutable ServiceLogs: string option
    mutable ServiceTail: string option
    mutable ServiceSleep: string option
    mutable ServiceWake: string option
    mutable ServiceDestroy: string option
    mutable ServiceExecute: string option
    mutable ServiceCommand: string option
    mutable ServiceDumpBootstrap: string option
    mutable ServiceDumpFile: string option
    mutable ServiceResize: string option
    mutable ServiceSetUnfreezeOnDemand: string option
    mutable ServiceUnfreezeOnDemandValue: string option
    mutable ServiceSnapshot: string option
    mutable ServiceRestore: string option
    mutable ServiceFrom: string option
    mutable ServiceSnapshotName: string option
    mutable ServiceHot: bool
    mutable SnapshotList: bool
    mutable SnapshotInfo: string option
    mutable SnapshotDelete: string option
    mutable SnapshotClone: string option
    mutable SnapshotType: string option
    mutable SnapshotName: string option
    mutable SnapshotShell: string option
    mutable SnapshotPorts: string option
    mutable EnvFile: string option
    mutable EnvAction: string option
    mutable EnvTarget: string option
    mutable KeyExtend: bool
    // Image command options
    mutable ImageList: bool
    mutable ImageInfo: string option
    mutable ImageDelete: string option
    mutable ImageLock: string option
    mutable ImageUnlock: string option
    mutable ImagePublish: string option
    mutable ImageSourceType: string option
    mutable ImageVisibility: string option
    mutable ImageVisibilityMode: string option
    mutable ImageSpawn: string option
    mutable ImageClone: string option
    mutable ImageName: string option
    mutable ImagePorts: string option
}

let getApiKeys (argsKey: string option) =
    let publicKey = Environment.GetEnvironmentVariable("UNSANDBOX_PUBLIC_KEY")
    let secretKey = Environment.GetEnvironmentVariable("UNSANDBOX_SECRET_KEY")

    // Fall back to UNSANDBOX_API_KEY for backwards compatibility
    if String.IsNullOrEmpty(publicKey) || String.IsNullOrEmpty(secretKey) then
        let legacyKey = match argsKey with | Some k -> k | None -> Environment.GetEnvironmentVariable("UNSANDBOX_API_KEY")
        if String.IsNullOrEmpty(legacyKey) then
            eprintfn "%sError: UNSANDBOX_PUBLIC_KEY and UNSANDBOX_SECRET_KEY not set%s" red reset
            exit 1
        (legacyKey, null)
    else
        (publicKey, secretKey)

let detectLanguage (filename: string) =
    let dotIndex = filename.LastIndexOf('.')
    if dotIndex = -1 then
        failwith "Cannot detect language: no file extension"
    let ext = filename.Substring(dotIndex).ToLower()
    match Map.tryFind ext extMap with
    | Some lang -> lang
    | None -> failwithf "Unsupported file extension: %s" ext

let jsonEscape (s: string) =
    let sb = StringBuilder("\"")
    for c in s do
        match c with
        | '"' -> sb.Append("\\\"") |> ignore
        | '\\' -> sb.Append("\\\\") |> ignore
        | '\n' -> sb.Append("\\n") |> ignore
        | '\r' -> sb.Append("\\r") |> ignore
        | '\t' -> sb.Append("\\t") |> ignore
        | _ -> sb.Append(c) |> ignore
    sb.Append("\"") |> ignore
    sb.ToString()

let rec toJson (obj: obj) =
    match obj with
    | null -> "null"
    | :? string as s -> jsonEscape s
    | :? int as i -> i.ToString()
    | :? float as f -> f.ToString()
    | :? bool as b -> b.ToString().ToLower()
    | :? Map<string, obj> as m ->
        let entries = m |> Map.toSeq |> Seq.map (fun (k, v) -> sprintf "\"%s\":%s" k (toJson v)) |> String.concat ","
        sprintf "{%s}" entries
    | :? ResizeArray<obj> as lst ->
        let items = lst |> Seq.map toJson |> String.concat ","
        sprintf "[%s]" items
    | :? (string * obj) list as lst ->
        let entries = lst |> List.map (fun (k, v) -> sprintf "\"%s\":%s" k (toJson v)) |> String.concat ","
        sprintf "{%s}" entries
    | _ -> jsonEscape (obj.ToString())

let extractJsonValue (json: string) (key: string) =
    let pattern = sprintf "\"%s\":" key
    let startIndex = json.IndexOf(pattern)
    if startIndex = -1 then None
    else
        let mutable idx = startIndex + pattern.Length
        while idx < json.Length && Char.IsWhiteSpace(json.[idx]) do
            idx <- idx + 1

        if json.[idx] = '"' then
            idx <- idx + 1
            let sb = StringBuilder()
            let mutable escaped = false
            let mutable found = false
            let mutable i = idx
            while i < json.Length && not found do
                let c = json.[i]
                if escaped then
                    match c with
                    | 'n' -> sb.Append('\n') |> ignore
                    | 'r' -> sb.Append('\r') |> ignore
                    | 't' -> sb.Append('\t') |> ignore
                    | '"' -> sb.Append('"') |> ignore
                    | '\\' -> sb.Append('\\') |> ignore
                    | _ -> sb.Append(c) |> ignore
                    escaped <- false
                else if c = '\\' then
                    escaped <- true
                else if c = '"' then
                    found <- true
                else
                    sb.Append(c) |> ignore
                i <- i + 1
            Some (sb.ToString())
        else
            let sb = StringBuilder()
            let mutable i = idx
            while i < json.Length && (Char.IsDigit(json.[i]) || json.[i] = '-') do
                sb.Append(json.[i]) |> ignore
                i <- i + 1
            Some (sb.ToString())

let parseJson (json: string) =
    let trimmed = json.Trim()
    if not (trimmed.StartsWith("{")) then Map.empty
    else
        let result = ResizeArray<string * obj>()
        let mutable i = 1

        while i < trimmed.Length do
            while i < trimmed.Length && Char.IsWhiteSpace(trimmed.[i]) do i <- i + 1
            if trimmed.[i] = '}' then i <- trimmed.Length
            elif trimmed.[i] = '"' then
                let keyStart = i + 1
                i <- i + 1
                while i < trimmed.Length && trimmed.[i] <> '"' do
                    if trimmed.[i] = '\\' then i <- i + 1
                    i <- i + 1
                let key = trimmed.Substring(keyStart, i - keyStart).Replace("\\\"", "\"").Replace("\\\\", "\\")
                i <- i + 1

                while i < trimmed.Length && (Char.IsWhiteSpace(trimmed.[i]) || trimmed.[i] = ':') do i <- i + 1

                let value = extractJsonValue trimmed key
                match value with
                | Some v -> result.Add((key, box v))
                | None -> ()

                while i < trimmed.Length && (Char.IsWhiteSpace(trimmed.[i]) || trimmed.[i] = ',' || trimmed.[i] = '"' || Char.IsLetterOrDigit(trimmed.[i]) || trimmed.[i] = '\\') do i <- i + 1
            else
                i <- i + 1

        result |> Seq.map (fun (k, v) -> k, v) |> Map.ofSeq

// Custom exception for HTTP errors with status code
exception HttpException of int * string

let apiRequestWithHeaders (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) (sudoOtp: string option) (sudoChallengeId: string option) =
    ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls

    let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest
    request.Method <- method
    request.ContentType <- "application/json"
    request.Timeout <- 300000

    let body = match data with | Some d -> toJson (box d) | None -> ""

    // Add HMAC authentication headers if secretKey is provided
    if not (String.IsNullOrEmpty(secretKey)) then
        let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
        let message = sprintf "%d:%s:%s:%s" timestamp method endpoint body

        use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))
        let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message))
        let signature = BitConverter.ToString(hash).Replace("-", "").ToLower()

        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
        request.Headers.Add("X-Timestamp", timestamp.ToString())
        request.Headers.Add("X-Signature", signature)
    else
        // Legacy API key authentication
        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)

    // Add sudo OTP headers if provided
    match sudoOtp with
    | Some otp -> request.Headers.Add("X-Sudo-OTP", otp)
    | None -> ()

    match sudoChallengeId with
    | Some cid -> request.Headers.Add("X-Sudo-Challenge", cid)
    | None -> ()

    match data with
    | Some d ->
        let bytes = Encoding.UTF8.GetBytes(body)
        request.ContentLength <- int64 bytes.Length
        use stream = request.GetRequestStream()
        stream.Write(bytes, 0, bytes.Length)
    | None -> ()

    try
        use response = request.GetResponse() :?> HttpWebResponse
        if response.StatusCode <> HttpStatusCode.OK then
            failwithf "HTTP %A" response.StatusCode
        use reader = new StreamReader(response.GetResponseStream())
        let responseText = reader.ReadToEnd()
        parseJson responseText
    with
    | :? WebException as ex ->
        let errorMsg =
            if ex.Response <> null then
                use reader = new StreamReader(ex.Response.GetResponseStream())
                reader.ReadToEnd()
            else
                ex.Message

        let statusCode =
            if ex.Response <> null then
                let httpResponse = ex.Response :?> HttpWebResponse
                int httpResponse.StatusCode
            else
                0

        // Check for clock drift error
        if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then
            eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset
            eprintfn "%sYour computer's clock may have drifted.%s" yellow reset
            eprintfn "Check your system time and sync with NTP if needed:"
            eprintfn "  Linux:   sudo ntpdate -s time.nist.gov"
            eprintfn "  macOS:   sudo sntp -sS time.apple.com"
            eprintfn "  Windows: w32tm /resync%s" reset
            exit 1

        raise (HttpException(statusCode, errorMsg))

let apiRequest (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) =
    apiRequestWithHeaders endpoint method data publicKey secretKey None None

// Handle 428 sudo OTP challenge - prompts user for OTP and retries the request
let handleSudoChallenge (responseBody: string) (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) =
    let challengeId = extractJsonValue responseBody "challenge_id"

    eprintfn "%sConfirmation required. Check your email for a one-time code.%s" yellow reset
    eprintf "Enter OTP: "

    let otp = Console.ReadLine()
    if String.IsNullOrEmpty(otp) then
        failwith "Operation cancelled"

    let otp = otp.Trim()

    // Retry the request with sudo headers
    apiRequestWithHeaders endpoint method data publicKey secretKey (Some otp) challengeId

// Wrapper for destructive operations that may require 428 sudo OTP
let apiRequestWithSudo (endpoint: string) (method: string) (data: (string * obj) list option) (publicKey: string) (secretKey: string) =
    try
        apiRequest endpoint method data publicKey secretKey
    with
    | HttpException(428, responseBody) ->
        handleSudoChallenge responseBody endpoint method data publicKey secretKey
    | HttpException(_, errorMsg) ->
        failwithf "HTTP error - %s" errorMsg

let apiRequestPatch (endpoint: string) (data: (string * obj) list) (publicKey: string) (secretKey: string) =
    ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls

    let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest
    request.Method <- "PATCH"
    request.ContentType <- "application/json"
    request.Timeout <- 300000

    let body = toJson (box data)

    // Add HMAC authentication headers if secretKey is provided
    if not (String.IsNullOrEmpty(secretKey)) then
        let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
        let message = sprintf "%d:%s:%s:%s" timestamp "PATCH" endpoint body

        use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))
        let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message))
        let signature = BitConverter.ToString(hash).Replace("-", "").ToLower()

        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
        request.Headers.Add("X-Timestamp", timestamp.ToString())
        request.Headers.Add("X-Signature", signature)
    else
        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)

    let bytes = Encoding.UTF8.GetBytes(body)
    request.ContentLength <- int64 bytes.Length
    use stream = request.GetRequestStream()
    stream.Write(bytes, 0, bytes.Length)

    try
        use response = request.GetResponse() :?> HttpWebResponse
        if response.StatusCode <> HttpStatusCode.OK then
            failwithf "HTTP %A" response.StatusCode
        use reader = new StreamReader(response.GetResponseStream())
        let responseText = reader.ReadToEnd()
        parseJson responseText
    with
    | :? WebException as ex ->
        let errorMsg =
            if ex.Response <> null then
                use reader = new StreamReader(ex.Response.GetResponseStream())
                reader.ReadToEnd()
            else
                ex.Message

        // Check for clock drift error
        if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then
            eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset
            eprintfn "%sYour computer's clock may have drifted.%s" yellow reset
            eprintfn "Check your system time and sync with NTP if needed:"
            eprintfn "  Linux:   sudo ntpdate -s time.nist.gov"
            eprintfn "  macOS:   sudo sntp -sS time.apple.com"
            eprintfn "  Windows: w32tm /resync%s" reset
            exit 1

        failwithf "HTTP error - %s" errorMsg

let apiRequestText (endpoint: string) (method: string) (body: string) (publicKey: string) (secretKey: string) =
    ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls

    let request = WebRequest.Create(apiBase + endpoint) :?> HttpWebRequest
    request.Method <- method
    request.ContentType <- "text/plain"
    request.Timeout <- 300000

    let bodyContent = if body = null then "" else body

    // Add HMAC authentication headers if secretKey is provided
    if not (String.IsNullOrEmpty(secretKey)) then
        let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
        let message = sprintf "%d:%s:%s:%s" timestamp method endpoint bodyContent

        use hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey))
        let hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message))
        let signature = BitConverter.ToString(hash).Replace("-", "").ToLower()

        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
        request.Headers.Add("X-Timestamp", timestamp.ToString())
        request.Headers.Add("X-Signature", signature)
    else
        request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)

    if not (String.IsNullOrEmpty(bodyContent)) then
        let bytes = Encoding.UTF8.GetBytes(bodyContent)
        request.ContentLength <- int64 bytes.Length
        use stream = request.GetRequestStream()
        stream.Write(bytes, 0, bytes.Length)

    try
        use response = request.GetResponse() :?> HttpWebResponse
        use reader = new StreamReader(response.GetResponseStream())
        reader.ReadToEnd()
    with
    | :? WebException as ex ->
        let errorMsg =
            if ex.Response <> null then
                use reader = new StreamReader(ex.Response.GetResponseStream())
                reader.ReadToEnd()
            else
                ex.Message
        failwithf "HTTP error - %s" errorMsg

let getLanguagesCachePath () =
    let home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
    Path.Combine(home, ".unsandbox", "languages.json")

let loadLanguagesCache () =
    let cachePath = getLanguagesCachePath ()
    if File.Exists(cachePath) then
        try
            let content = File.ReadAllText(cachePath)
            let timestamp = extractJsonValue content "timestamp"
            match timestamp with
            | Some ts ->
                let cacheTime = int64 ts
                let now = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
                if now - cacheTime < int64 languagesCacheTtl then
                    Some content
                else
                    None
            | None -> None
        with _ -> None
    else
        None

let saveLanguagesCache (languages: string) =
    let cachePath = getLanguagesCachePath ()
    let cacheDir = Path.GetDirectoryName(cachePath)
    if not (Directory.Exists(cacheDir)) then
        Directory.CreateDirectory(cacheDir) |> ignore
    let timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
    let cacheContent = sprintf "{\"languages\":%s,\"timestamp\":%d}" languages timestamp
    File.WriteAllText(cachePath, cacheContent)

let readEnvFile (path: string) =
    if not (File.Exists(path)) then
        failwithf "Env file not found: %s" path
    File.ReadAllText(path)

let buildEnvContent (envs: ResizeArray<string>) (envFile: string option) =
    let lines = ResizeArray<string>()

    // Add from -e flags
    for env in envs do
        lines.Add(env)

    // Add from --env-file
    match envFile with
    | Some path ->
        let content = readEnvFile path
        for line in content.Split('\n') do
            let trimmed = line.Trim()
            if not (String.IsNullOrEmpty(trimmed)) && not (trimmed.StartsWith("#")) then
                lines.Add(trimmed)
    | None -> ()

    String.Join("\n", lines)

let serviceEnvStatus (serviceId: string) (publicKey: string) (secretKey: string) =
    apiRequest (sprintf "/services/%s/env" serviceId) "GET" None publicKey secretKey

let serviceEnvSet (serviceId: string) (envContent: string) (publicKey: string) (secretKey: string) =
    let maxEnvContentSize = 65536
    if envContent.Length > maxEnvContentSize then
        eprintfn "%sError: Env content exceeds maximum size of 64KB%s" red reset
        false
    else
        try
            apiRequestText (sprintf "/services/%s/env" serviceId) "PUT" envContent publicKey secretKey |> ignore
            true
        with _ ->
            false

let serviceEnvExport (serviceId: string) (publicKey: string) (secretKey: string) =
    apiRequest (sprintf "/services/%s/env/export" serviceId) "POST" None publicKey secretKey

let serviceEnvDelete (serviceId: string) (publicKey: string) (secretKey: string) =
    try
        apiRequest (sprintf "/services/%s/env" serviceId) "DELETE" None publicKey secretKey |> ignore
        true
    with _ ->
        false

let cmdServiceEnv (args: Args) (publicKey: string) (secretKey: string) =
    match args.EnvAction with
    | Some "status" ->
        match args.EnvTarget with
        | Some target ->
            let result = serviceEnvStatus target publicKey secretKey
            match result.TryFind "has_vault" with
            | Some hasVault when hasVault.ToString() = "True" ->
                printfn "%sVault: configured%s" green reset
                match result.TryFind "env_count" with
                | Some count -> printfn "Variables: %s" (count.ToString())
                | None -> ()
                match result.TryFind "updated_at" with
                | Some updated -> printfn "Updated: %s" (updated.ToString())
                | None -> ()
            | _ ->
                printfn "%sVault: not configured%s" yellow reset
        | None ->
            eprintfn "%sError: service env status requires service ID%s" red reset
            exit 1
    | Some "set" ->
        match args.EnvTarget with
        | Some target ->
            if args.Env.Count = 0 && args.EnvFile.IsNone then
                eprintfn "%sError: service env set requires -e or --env-file%s" red reset
                exit 1
            let envContent = buildEnvContent args.Env args.EnvFile
            if serviceEnvSet target envContent publicKey secretKey then
                printfn "%sVault updated for service %s%s" green target reset
            else
                eprintfn "%sError: Failed to update vault%s" red reset
                exit 1
        | None ->
            eprintfn "%sError: service env set requires service ID%s" red reset
            exit 1
    | Some "export" ->
        match args.EnvTarget with
        | Some target ->
            let result = serviceEnvExport target publicKey secretKey
            match result.TryFind "content" with
            | Some content -> printf "%s" (content.ToString())
            | None -> ()
        | None ->
            eprintfn "%sError: service env export requires service ID%s" red reset
            exit 1
    | Some "delete" ->
        match args.EnvTarget with
        | Some target ->
            if serviceEnvDelete target publicKey secretKey then
                printfn "%sVault deleted for service %s%s" green target reset
            else
                eprintfn "%sError: Failed to delete vault%s" red reset
                exit 1
        | None ->
            eprintfn "%sError: service env delete requires service ID%s" red reset
            exit 1
    | Some action ->
        eprintfn "%sError: Unknown env action: %s%s" red action reset
        eprintfn "Usage: un.fs service env <status|set|export|delete> <service_id>"
        exit 1
    | None ->
        eprintfn "%sError: env action required%s" red reset
        exit 1

let cmdExecute (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey
    let code = File.ReadAllText(args.SourceFile.Value)
    let language = detectLanguage args.SourceFile.Value

    let mutable payload = [("language", box language); ("code", box code)]

    if args.Env.Count > 0 then
        let envVars = args.Env |> Seq.choose (fun e ->
            let parts = e.Split([|'='|], 2)
            if parts.Length = 2 then Some (parts.[0], box parts.[1]) else None
        ) |> Seq.toList
        if not (List.isEmpty envVars) then
            payload <- payload @ [("env", box envVars)]

    if args.Files.Count > 0 then
        let inputFiles = args.Files |> Seq.map (fun filepath ->
            let content = File.ReadAllBytes(filepath)
            [("filename", box (Path.GetFileName(filepath))); ("content_base64", box (Convert.ToBase64String(content)))]
        ) |> Seq.toList
        payload <- payload @ [("input_files", box inputFiles)]

    if args.Artifacts then
        payload <- payload @ [("return_artifacts", box true)]
    if args.Network.IsSome then
        payload <- payload @ [("network", box args.Network.Value)]
    if args.Vcpu > 0 then
        payload <- payload @ [("vcpu", box args.Vcpu)]

    let result = apiRequest "/execute" "POST" (Some payload) publicKey secretKey

    match result.TryFind "stdout" with
    | Some stdout when not (String.IsNullOrEmpty(stdout.ToString())) ->
        printf "%s%s%s" blue (stdout.ToString()) reset
    | _ -> ()

    match result.TryFind "stderr" with
    | Some stderr when not (String.IsNullOrEmpty(stderr.ToString())) ->
        eprintf "%s%s%s" red (stderr.ToString()) reset
    | _ -> ()

    if args.Artifacts && result.ContainsKey("artifacts") then
        let outDir = match args.OutputDir with | Some d -> d | None -> "."
        Directory.CreateDirectory(outDir) |> ignore
        eprintfn "%sSaved artifacts to %s%s" green outDir reset

    let exitCode = match result.TryFind "exit_code" with | Some ec -> int (ec.ToString()) | None -> 0
    exit exitCode

let cmdSession (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    if args.SessionSnapshot.IsSome then
        let mutable payload = []
        if args.SessionSnapshotName.IsSome then
            payload <- payload @ [("name", box args.SessionSnapshotName.Value)]
        if args.SessionHot then
            payload <- payload @ [("hot", box true)]
        let result = apiRequest (sprintf "/sessions/%s/snapshot" args.SessionSnapshot.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sSnapshot created%s" green reset
        printfn "%s" (toJson (box result))
    elif args.SessionRestore.IsSome then
        // --restore takes snapshot ID directly, calls /snapshots/:id/restore
        let result = apiRequest (sprintf "/snapshots/%s/restore" args.SessionRestore.Value) "POST" None publicKey secretKey
        printfn "%sSession restored from snapshot%s" green reset
        printfn "%s" (toJson (box result))
    elif args.SessionList then
        let result = apiRequest "/sessions" "GET" None publicKey secretKey
        printfn "%-40s %-10s %-10s %s" "ID" "Shell" "Status" "Created"
        printfn "No sessions (list parsing not implemented)"
    elif args.SessionKill.IsSome then
        let result = apiRequest (sprintf "/sessions/%s" args.SessionKill.Value) "DELETE" None publicKey secretKey
        printfn "%sSession terminated: %s%s" green args.SessionKill.Value reset
    else
        let mutable payload = [("shell", box (match args.SessionShell with | Some s -> s | None -> "bash"))]
        if args.Network.IsSome then
            payload <- payload @ [("network", box args.Network.Value)]
        if args.Vcpu > 0 then
            payload <- payload @ [("vcpu", box args.Vcpu)]

        if args.Files.Count > 0 then
            let inputFiles = args.Files |> Seq.map (fun filepath ->
                let content = File.ReadAllBytes(filepath)
                [("filename", box (Path.GetFileName(filepath))); ("content_base64", box (Convert.ToBase64String(content)))]
            ) |> Seq.toList
            payload <- payload @ [("input_files", box inputFiles)]

        printfn "%sCreating session...%s" yellow reset
        let result = apiRequest "/sessions" "POST" (Some payload) publicKey secretKey
        match result.TryFind "id" with
        | Some id -> printfn "%sSession created: %s%s" green (id.ToString()) reset
        | None -> printfn "%sSession created%s" green reset
        printfn "%s(Interactive sessions require WebSocket - use un2 for full support)%s" yellow reset

let openBrowser (url: string) =
    try
        let os = Environment.OSVersion.Platform
        let cmd =
            if os = PlatformID.Unix || os = PlatformID.MacOSX then
                if System.IO.File.Exists("/usr/bin/xdg-open") then
                    System.Diagnostics.Process.Start("xdg-open", url)
                else
                    System.Diagnostics.Process.Start("open", url)
            else
                System.Diagnostics.Process.Start("cmd", sprintf "/c start %s" url)
        cmd.WaitForExit()
    with ex ->
        eprintfn "%sError opening browser: %s%s" red ex.Message reset

let cmdKey (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    ServicePointManager.SecurityProtocol <- SecurityProtocolType.Tls12 ||| SecurityProtocolType.Tls11 ||| SecurityProtocolType.Tls

    let request = WebRequest.Create(portalBase + "/keys/validate") :?> HttpWebRequest
    request.Method <- "POST"
    request.ContentType <- "application/json"
    request.Headers.Add("Authorization", sprintf "Bearer %s" publicKey)
    request.Timeout <- 30000

    try
        use response = request.GetResponse() :?> HttpWebResponse
        use reader = new StreamReader(response.GetResponseStream())
        let responseText = reader.ReadToEnd()
        let result = parseJson responseText

        let resultPublicKey = match result.TryFind "public_key" with | Some v -> v.ToString() | None -> "N/A"
        let tier = match result.TryFind "tier" with | Some v -> v.ToString() | None -> "N/A"
        let status = match result.TryFind "status" with | Some v -> v.ToString() | None -> "N/A"
        let expiresAt = match result.TryFind "expires_at" with | Some v -> v.ToString() | None -> "N/A"
        let timeRemaining = match result.TryFind "time_remaining" with | Some v -> v.ToString() | None -> "N/A"
        let rateLimit = match result.TryFind "rate_limit" with | Some v -> v.ToString() | None -> "N/A"
        let burst = match result.TryFind "burst" with | Some v -> v.ToString() | None -> "N/A"
        let concurrency = match result.TryFind "concurrency" with | Some v -> v.ToString() | None -> "N/A"
        let expired = match result.TryFind "expired" with | Some v -> v.ToString() = "True" | None -> false

        if args.KeyExtend && resultPublicKey <> "N/A" then
            let extendUrl = sprintf "%s/keys/extend?pk=%s" portalBase resultPublicKey
            printfn "%sOpening browser to extend key...%s" blue reset
            openBrowser extendUrl
        elif expired then
            printfn "%sExpired%s" red reset
            printfn "Public Key: %s" resultPublicKey
            printfn "Tier: %s" tier
            printfn "Expired: %s" expiresAt
            printfn "%sTo renew: Visit https://unsandbox.com/keys/extend%s" yellow reset
            exit 1
        else
            printfn "%sValid%s" green reset
            printfn "Public Key: %s" resultPublicKey
            printfn "Tier: %s" tier
            printfn "Status: %s" status
            printfn "Expires: %s" expiresAt
            printfn "Time Remaining: %s" timeRemaining
            printfn "Rate Limit: %s" rateLimit
            printfn "Burst: %s" burst
            printfn "Concurrency: %s" concurrency
    with
    | :? WebException as ex ->
        printfn "%sInvalid%s" red reset
        let errorMsg =
            if ex.Response <> null then
                use reader = new StreamReader(ex.Response.GetResponseStream())
                let body = reader.ReadToEnd()
                try
                    let errorResult = parseJson body
                    match errorResult.TryFind "error" with
                    | Some err -> err.ToString()
                    | None -> body
                with _ -> body
            else
                ex.Message

        // Check for clock drift error
        if errorMsg.Contains("timestamp") && (errorMsg.Contains("401") || errorMsg.Contains("expired") || errorMsg.Contains("invalid")) then
            eprintfn "%sError: Request timestamp expired (must be within 5 minutes of server time)%s" red reset
            eprintfn "%sYour computer's clock may have drifted.%s" yellow reset
            eprintfn "Check your system time and sync with NTP if needed:"
            eprintfn "  Linux:   sudo ntpdate -s time.nist.gov"
            eprintfn "  macOS:   sudo sntp -sS time.apple.com"
            eprintfn "  Windows: w32tm /resync%s" reset
            exit 1

        printfn "Reason: %s" errorMsg
        exit 1

let cmdLanguages (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    // Try to load from cache first
    let cachedResponse = loadLanguagesCache ()
    let response =
        match cachedResponse with
        | Some cached -> cached
        | None ->
            // Fetch from API and cache the result
            let result = apiRequest "/languages" "GET" None publicKey secretKey
            // Extract the languages array as a string for caching
            match result.TryFind "languages" with
            | Some langs ->
                let langStr = langs.ToString()
                let languagesJson =
                    if langStr.StartsWith("[") && langStr.EndsWith("]") then
                        langStr
                    else
                        sprintf "[\"%s\"]" langStr
                saveLanguagesCache languagesJson
            | None -> ()
            // Return the result as a formatted string
            sprintf "{\"languages\":%s}" (match result.TryFind "languages" with | Some l -> l.ToString() | None -> "[]")

    // Parse the response (either from cache or fresh)
    let langStr =
        match extractJsonValue response "languages" with
        | Some l -> l
        | None ->
            // Try to find the array directly
            let start = response.IndexOf("[")
            let endIdx = response.LastIndexOf("]")
            if start >= 0 && endIdx > start then
                response.Substring(start, endIdx - start + 1)
            else
                "[]"

    let languages =
        if langStr.StartsWith("[") && langStr.EndsWith("]") then
            langStr.Substring(1, langStr.Length - 2).Split(',')
            |> Array.map (fun s -> s.Trim().Trim('"'))
            |> Array.filter (fun s -> not (String.IsNullOrEmpty(s)))
        else
            [| langStr |]

    if args.LanguagesJson then
        // Output as JSON array
        let jsonArray = sprintf "[%s]" (languages |> Array.map (sprintf "\"%s\"") |> String.concat ",")
        printfn "%s" jsonArray
    else
        // Output one language per line
        for lang in languages do
            printfn "%s" lang

let cmdImage (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    if args.ImageList then
        let result = apiRequest "/images" "GET" None publicKey secretKey
        printfn "%s" (toJson (box result))
    elif args.ImageInfo.IsSome then
        let result = apiRequest (sprintf "/images/%s" args.ImageInfo.Value) "GET" None publicKey secretKey
        printfn "%s" (toJson (box result))
    elif args.ImageDelete.IsSome then
        let result = apiRequestWithSudo (sprintf "/images/%s" args.ImageDelete.Value) "DELETE" None publicKey secretKey
        printfn "%sImage deleted: %s%s" green args.ImageDelete.Value reset
    elif args.ImageLock.IsSome then
        let result = apiRequest (sprintf "/images/%s/lock" args.ImageLock.Value) "POST" None publicKey secretKey
        printfn "%sImage locked: %s%s" green args.ImageLock.Value reset
    elif args.ImageUnlock.IsSome then
        let result = apiRequestWithSudo (sprintf "/images/%s/unlock" args.ImageUnlock.Value) "POST" None publicKey secretKey
        printfn "%sImage unlocked: %s%s" green args.ImageUnlock.Value reset
    elif args.ImagePublish.IsSome then
        if args.ImageSourceType.IsNone then
            eprintfn "%sError: --source-type required (service or snapshot)%s" red reset
            exit 1
        let mutable payload = [("source_type", box args.ImageSourceType.Value); ("source_id", box args.ImagePublish.Value)]
        if args.ImageName.IsSome then
            payload <- payload @ [("name", box args.ImageName.Value)]
        let result = apiRequest "/images/publish" "POST" (Some payload) publicKey secretKey
        printfn "%sImage published%s" green reset
        printfn "%s" (toJson (box result))
    elif args.ImageVisibility.IsSome then
        if args.ImageVisibilityMode.IsNone then
            eprintfn "%sError: --visibility requires MODE (private, unlisted, or public)%s" red reset
            exit 1
        let payload = [("visibility", box args.ImageVisibilityMode.Value)]
        let result = apiRequest (sprintf "/images/%s/visibility" args.ImageVisibility.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sImage visibility set to %s%s" green args.ImageVisibilityMode.Value reset
    elif args.ImageSpawn.IsSome then
        let mutable payload = []
        if args.ImageName.IsSome then
            payload <- payload @ [("name", box args.ImageName.Value)]
        if args.ImagePorts.IsSome then
            let ports = args.ImagePorts.Value.Split(',') |> Array.map (fun p -> box (int (p.Trim())))
            payload <- payload @ [("ports", box ports)]
        let result = apiRequest (sprintf "/images/%s/spawn" args.ImageSpawn.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sService spawned from image%s" green reset
        printfn "%s" (toJson (box result))
    elif args.ImageClone.IsSome then
        let mutable payload = []
        if args.ImageName.IsSome then
            payload <- payload @ [("name", box args.ImageName.Value)]
        let result = apiRequest (sprintf "/images/%s/clone" args.ImageClone.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sImage cloned%s" green reset
        printfn "%s" (toJson (box result))
    else
        eprintfn "%sError: Use --list, --info ID, --delete ID, --lock ID, --unlock ID, --publish ID, --visibility ID MODE, --spawn ID, or --clone ID%s" red reset
        exit 1

let cmdSnapshot (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    if args.SnapshotList then
        let result = apiRequest "/snapshots" "GET" None publicKey secretKey
        printfn "%s" (toJson (box result))
    elif args.SnapshotInfo.IsSome then
        let result = apiRequest (sprintf "/snapshots/%s" args.SnapshotInfo.Value) "GET" None publicKey secretKey
        printfn "%s" (toJson (box result))
    elif args.SnapshotDelete.IsSome then
        let result = apiRequestWithSudo (sprintf "/snapshots/%s" args.SnapshotDelete.Value) "DELETE" None publicKey secretKey
        printfn "%sSnapshot deleted: %s%s" green args.SnapshotDelete.Value reset
    elif args.SnapshotClone.IsSome then
        if args.SnapshotType.IsNone then
            eprintfn "%sError: --type required (session or service)%s" red reset
            exit 1
        let mutable payload = [("type", box args.SnapshotType.Value)]
        if args.SnapshotName.IsSome then
            payload <- payload @ [("name", box args.SnapshotName.Value)]
        if args.SnapshotShell.IsSome then
            payload <- payload @ [("shell", box args.SnapshotShell.Value)]
        if args.SnapshotPorts.IsSome then
            let ports = args.SnapshotPorts.Value.Split(',') |> Array.map (fun p -> box (int (p.Trim())))
            payload <- payload @ [("ports", box ports)]
        let result = apiRequest (sprintf "/snapshots/%s/clone" args.SnapshotClone.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sCreated from snapshot%s" green reset
        printfn "%s" (toJson (box result))
    else
        eprintfn "%sError: Use --list, --info ID, --delete ID, or --clone ID --type TYPE%s" red reset
        exit 1

let cmdService (args: Args) =
    let (publicKey, secretKey) = getApiKeys args.ApiKey

    // Handle env subcommand
    if args.EnvAction.IsSome then
        cmdServiceEnv args publicKey secretKey
    elif args.ServiceSnapshot.IsSome then
        let mutable payload = []
        if args.ServiceSnapshotName.IsSome then
            payload <- payload @ [("name", box args.ServiceSnapshotName.Value)]
        if args.ServiceHot then
            payload <- payload @ [("hot", box true)]
        let result = apiRequest (sprintf "/services/%s/snapshot" args.ServiceSnapshot.Value) "POST" (Some payload) publicKey secretKey
        printfn "%sSnapshot created%s" green reset
        printfn "%s" (toJson (box result))
    elif args.ServiceRestore.IsSome then
        // --restore takes snapshot ID directly, calls /snapshots/:id/restore
        let result = apiRequest (sprintf "/snapshots/%s/restore" args.ServiceRestore.Value) "POST" None publicKey secretKey
        printfn "%sService restored from snapshot%s" green reset
        printfn "%s" (toJson (box result))
    elif args.ServiceList then
        let result = apiRequest "/services" "GET" None publicKey secretKey
        printfn "%-20s %-15s %-10s %-15s %s" "ID" "Name" "Status" "Ports" "Domains"
        printfn "No services (list parsing not implemented)"
    elif args.ServiceInfo.IsSome then
        let result = apiRequest (sprintf "/services/%s" args.ServiceInfo.Value) "GET" None publicKey secretKey
        printfn "%s" (toJson (box result))
    elif args.ServiceLogs.IsSome then
        let result = apiRequest (sprintf "/services/%s/logs" args.ServiceLogs.Value) "GET" None publicKey secretKey
        match result.TryFind "logs" with
        | Some logs -> printfn "%s" (logs.ToString())
        | None -> ()
    elif args.ServiceTail.IsSome then
        let result = apiRequest (sprintf "/services/%s/logs?lines=9000" args.ServiceTail.Value) "GET" None publicKey secretKey
        match result.TryFind "logs" with
        | Some logs -> printfn "%s" (logs.ToString())
        | None -> ()
    elif args.ServiceSleep.IsSome then
        let result = apiRequest (sprintf "/services/%s/freeze" args.ServiceSleep.Value) "POST" None publicKey secretKey
        printfn "%sService frozen: %s%s" green args.ServiceSleep.Value reset
    elif args.ServiceWake.IsSome then
        let result = apiRequest (sprintf "/services/%s/unfreeze" args.ServiceWake.Value) "POST" None publicKey secretKey
        printfn "%sService unfreezing: %s%s" green args.ServiceWake.Value reset
    elif args.ServiceDestroy.IsSome then
        let result = apiRequestWithSudo (sprintf "/services/%s" args.ServiceDestroy.Value) "DELETE" None publicKey secretKey
        printfn "%sService destroyed: %s%s" green args.ServiceDestroy.Value reset
    elif args.ServiceResize.IsSome then
        if args.Vcpu <= 0 then
            eprintfn "%sError: --resize requires --vcpu N (1-8)%s" red reset
            exit 1
        let payload = [("vcpu", box args.Vcpu)]
        let result = apiRequestPatch (sprintf "/services/%s" args.ServiceResize.Value) payload publicKey secretKey
        let ram = args.Vcpu * 2
        printfn "%sService resized to %d vCPU, %d GB RAM%s" green args.Vcpu ram reset
    elif args.ServiceSetUnfreezeOnDemand.IsSome then
        let enabledStr = args.ServiceUnfreezeOnDemandValue.Value.ToLower()
        let enabled = enabledStr = "true" || enabledStr = "1" || enabledStr = "yes" || enabledStr = "on"
        let payload = [("unfreeze_on_demand", box enabled)]
        let result = apiRequestPatch (sprintf "/services/%s" args.ServiceSetUnfreezeOnDemand.Value) payload publicKey secretKey
        printfn "%sService unfreeze_on_demand set to %b: %s%s" green enabled args.ServiceSetUnfreezeOnDemand.Value reset
    elif args.ServiceExecute.IsSome then
        let payload = [("command", box args.ServiceCommand.Value)]
        let result = apiRequest (sprintf "/services/%s/execute" args.ServiceExecute.Value) "POST" (Some payload) publicKey secretKey
        match result.TryFind "stdout" with
        | Some stdout when not (String.IsNullOrEmpty(stdout.ToString())) ->
            printf "%s%s%s" blue (stdout.ToString()) reset
        | _ -> ()
        match result.TryFind "stderr" with
        | Some stderr when not (String.IsNullOrEmpty(stderr.ToString())) ->
            eprintf "%s%s%s" red (stderr.ToString()) reset
        | _ -> ()
    elif args.ServiceDumpBootstrap.IsSome then
        eprintfn "Fetching bootstrap script from %s..." args.ServiceDumpBootstrap.Value
        let payload = [("command", box "cat /tmp/bootstrap.sh")]
        let result = apiRequest (sprintf "/services/%s/execute" args.ServiceDumpBootstrap.Value) "POST" (Some payload) publicKey secretKey

        match result.TryFind "stdout" with
        | Some bootstrap when not (String.IsNullOrEmpty(bootstrap.ToString())) ->
            let bootstrapText = bootstrap.ToString()
            if args.ServiceDumpFile.IsSome then
                try
                    File.WriteAllText(args.ServiceDumpFile.Value, bootstrapText)
                    printfn "Bootstrap saved to %s" args.ServiceDumpFile.Value
                with ex ->
                    eprintfn "%sError: Could not write to %s: %s%s" red args.ServiceDumpFile.Value ex.Message reset
                    exit 1
            else
                printf "%s" bootstrapText
        | _ ->
            eprintfn "%sError: Failed to fetch bootstrap (service not running or no bootstrap file)%s" red reset
            exit 1
    elif args.ServiceName.IsSome then
        let mutable payload = [("name", box args.ServiceName.Value)]
        if args.ServicePorts.IsSome then
            let ports = args.ServicePorts.Value.Split(',') |> Array.map (fun p -> box (int (p.Trim())))
            payload <- payload @ [("ports", box ports)]
        if args.ServiceType.IsSome then
            payload <- payload @ [("service_type", box args.ServiceType.Value)]
        if args.ServiceBootstrap.IsSome then
            payload <- payload @ [("bootstrap", box args.ServiceBootstrap.Value)]
        if args.ServiceBootstrapFile.IsSome then
            if File.Exists(args.ServiceBootstrapFile.Value) then
                let content = File.ReadAllText(args.ServiceBootstrapFile.Value)
                payload <- payload @ [("bootstrap_content", box content)]
            else
                eprintfn "%sError: Bootstrap file not found: %s%s" red args.ServiceBootstrapFile.Value reset
                exit 1
        if args.Files.Count > 0 then
            let inputFiles = args.Files |> Seq.map (fun filepath ->
                let content = File.ReadAllBytes(filepath)
                [("filename", box (Path.GetFileName(filepath))); ("content_base64", box (Convert.ToBase64String(content)))]
            ) |> Seq.toList
            payload <- payload @ [("input_files", box inputFiles)]
        if args.Network.IsSome then
            payload <- payload @ [("network", box args.Network.Value)]
        if args.Vcpu > 0 then
            payload <- payload @ [("vcpu", box args.Vcpu)]

        let result = apiRequest "/services" "POST" (Some payload) publicKey secretKey
        let serviceId = match result.TryFind "id" with | Some id -> Some (id.ToString()) | None -> None
        match serviceId with
        | Some id -> printfn "%sService created: %s%s" green id reset
        | None -> printfn "%sService created%s" green reset
        match result.TryFind "name" with
        | Some name -> printfn "Name: %s" (name.ToString())
        | None -> ()
        match result.TryFind "url" with
        | Some url -> printfn "URL: %s" (url.ToString())
        | None -> ()

        // Auto-set vault if env vars were provided
        match serviceId with
        | Some id when args.Env.Count > 0 || args.EnvFile.IsSome ->
            let envContent = buildEnvContent args.Env args.EnvFile
            if not (String.IsNullOrEmpty(envContent)) then
                if serviceEnvSet id envContent publicKey secretKey then
                    printfn "%sVault configured with environment variables%s" green reset
                else
                    eprintfn "%sWarning: Failed to set vault%s" yellow reset
        | _ -> ()
    else
        eprintfn "%sError: Specify --name to create a service, or use --list, --info, etc.%s" red reset
        exit 1

let parseArgs (argv: string[]) =
    let args = {
        Command = None
        SourceFile = None
        ApiKey = None
        Network = None
        Vcpu = 0
        Env = ResizeArray<string>()
        Files = ResizeArray<string>()
        Artifacts = false
        OutputDir = None
        SessionList = false
        SessionShell = None
        SessionKill = None
        SessionSnapshot = None
        SessionRestore = None
        SessionFrom = None
        SessionSnapshotName = None
        SessionHot = false
        ServiceList = false
        LanguagesJson = false
        ServiceName = None
        ServicePorts = None
        ServiceType = None
        ServiceBootstrap = None
        ServiceBootstrapFile = None
        ServiceInfo = None
        ServiceLogs = None
        ServiceTail = None
        ServiceSleep = None
        ServiceWake = None
        ServiceDestroy = None
        ServiceExecute = None
        ServiceCommand = None
        ServiceDumpBootstrap = None
        ServiceDumpFile = None
        ServiceResize = None
        ServiceSetUnfreezeOnDemand = None
        ServiceUnfreezeOnDemandValue = None
        ServiceSnapshot = None
        ServiceRestore = None
        ServiceFrom = None
        ServiceSnapshotName = None
        ServiceHot = false
        SnapshotList = false
        SnapshotInfo = None
        SnapshotDelete = None
        SnapshotClone = None
        SnapshotType = None
        SnapshotName = None
        SnapshotShell = None
        SnapshotPorts = None
        EnvFile = None
        EnvAction = None
        EnvTarget = None
        KeyExtend = false
        ImageList = false
        ImageInfo = None
        ImageDelete = None
        ImageLock = None
        ImageUnlock = None
        ImagePublish = None
        ImageSourceType = None
        ImageVisibility = None
        ImageVisibilityMode = None
        ImageSpawn = None
        ImageClone = None
        ImageName = None
        ImagePorts = None
    }

    let mutable i = 0
    while i < argv.Length do
        match argv.[i] with
        | "session" -> args.Command <- Some "session"
        | "service" -> args.Command <- Some "service"
        | "snapshot" -> args.Command <- Some "snapshot"
        | "image" -> args.Command <- Some "image"
        | "key" -> args.Command <- Some "key"
        | "languages" -> args.Command <- Some "languages"
        | "--json" when args.Command = Some "languages" -> args.LanguagesJson <- true
        | "env" when args.Command = Some "service" ->
            // Parse: service env <action> <target>
            if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
                i <- i + 1
                args.EnvAction <- Some argv.[i]
                if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
                    i <- i + 1
                    args.EnvTarget <- Some argv.[i]
        | "-k" | "--api-key" -> i <- i + 1; args.ApiKey <- Some argv.[i]
        | "-n" | "--network" -> i <- i + 1; args.Network <- Some argv.[i]
        | "-v" | "--vcpu" -> i <- i + 1; args.Vcpu <- int argv.[i]
        | "-e" | "--env" -> i <- i + 1; args.Env.Add(argv.[i])
        | "--env-file" -> i <- i + 1; args.EnvFile <- Some argv.[i]
        | "-f" | "--files" -> i <- i + 1; args.Files.Add(argv.[i])
        | "-a" | "--artifacts" -> args.Artifacts <- true
        | "-o" | "--output-dir" -> i <- i + 1; args.OutputDir <- Some argv.[i]
        | "-l" | "--list" ->
            match args.Command with
            | Some "session" -> args.SessionList <- true
            | Some "service" -> args.ServiceList <- true
            | Some "image" -> args.ImageList <- true
            | Some "snapshot" -> args.SnapshotList <- true
            | _ -> ()
        | "-s" | "--shell" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotShell <- Some argv.[i]
            | _ -> args.SessionShell <- Some argv.[i]
        | "--kill" -> i <- i + 1; args.SessionKill <- Some argv.[i]
        | "--snapshot" ->
            i <- i + 1
            match args.Command with
            | Some "session" -> args.SessionSnapshot <- Some argv.[i]
            | Some "service" -> args.ServiceSnapshot <- Some argv.[i]
            | _ -> ()
        | "--restore" ->
            i <- i + 1
            match args.Command with
            | Some "session" -> args.SessionRestore <- Some argv.[i]
            | Some "service" -> args.ServiceRestore <- Some argv.[i]
            | _ -> ()
        | "--from" ->
            i <- i + 1
            match args.Command with
            | Some "session" -> args.SessionFrom <- Some argv.[i]
            | Some "service" -> args.ServiceFrom <- Some argv.[i]
            | _ -> ()
        | "--snapshot-name" ->
            i <- i + 1
            match args.Command with
            | Some "session" -> args.SessionSnapshotName <- Some argv.[i]
            | Some "service" -> args.ServiceSnapshotName <- Some argv.[i]
            | _ -> ()
        | "--hot" ->
            match args.Command with
            | Some "session" -> args.SessionHot <- true
            | Some "service" -> args.ServiceHot <- true
            | _ -> ()
        | "--info" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotInfo <- Some argv.[i]
            | _ -> args.ServiceInfo <- Some argv.[i]
        | "--delete" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotDelete <- Some argv.[i]
            | _ -> ()
        | "--clone" -> i <- i + 1; args.SnapshotClone <- Some argv.[i]
        | "--type" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotType <- Some argv.[i]
            | _ -> args.ServiceType <- Some argv.[i]
        | "--name" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotName <- Some argv.[i]
            | Some "image" -> args.ImageName <- Some argv.[i]
            | _ -> args.ServiceName <- Some argv.[i]
        | "--ports" ->
            i <- i + 1
            match args.Command with
            | Some "snapshot" -> args.SnapshotPorts <- Some argv.[i]
            | Some "image" -> args.ImagePorts <- Some argv.[i]
            | _ -> args.ServicePorts <- Some argv.[i]
        | "--bootstrap" -> i <- i + 1; args.ServiceBootstrap <- Some argv.[i]
        | "--bootstrap-file" -> i <- i + 1; args.ServiceBootstrapFile <- Some argv.[i]
        | "--logs" -> i <- i + 1; args.ServiceLogs <- Some argv.[i]
        | "--tail" -> i <- i + 1; args.ServiceTail <- Some argv.[i]
        | "--freeze" -> i <- i + 1; args.ServiceSleep <- Some argv.[i]
        | "--unfreeze" -> i <- i + 1; args.ServiceWake <- Some argv.[i]
        | "--destroy" -> i <- i + 1; args.ServiceDestroy <- Some argv.[i]
        | "--resize" -> i <- i + 1; args.ServiceResize <- Some argv.[i]
        | "--set-unfreeze-on-demand" ->
            i <- i + 1
            args.ServiceSetUnfreezeOnDemand <- Some argv.[i]
            if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
                i <- i + 1
                args.ServiceUnfreezeOnDemandValue <- Some argv.[i]
        | "--execute" -> i <- i + 1; args.ServiceExecute <- Some argv.[i]
        | "--command" -> i <- i + 1; args.ServiceCommand <- Some argv.[i]
        | "--dump-bootstrap" -> i <- i + 1; args.ServiceDumpBootstrap <- Some argv.[i]
        | "--dump-file" -> i <- i + 1; args.ServiceDumpFile <- Some argv.[i]
        | "--extend" -> args.KeyExtend <- true
        | "--info" ->
            i <- i + 1
            match args.Command with
            | Some "image" -> args.ImageInfo <- Some argv.[i]
            | _ -> args.ServiceInfo <- Some argv.[i]
        | "--delete" ->
            i <- i + 1
            match args.Command with
            | Some "image" -> args.ImageDelete <- Some argv.[i]
            | Some "snapshot" -> args.SnapshotDelete <- Some argv.[i]
            | _ -> ()
        | "--lock" ->
            i <- i + 1
            if args.Command = Some "image" then
                args.ImageLock <- Some argv.[i]
        | "--unlock" ->
            i <- i + 1
            if args.Command = Some "image" then
                args.ImageUnlock <- Some argv.[i]
        | "--publish" ->
            i <- i + 1
            if args.Command = Some "image" then
                args.ImagePublish <- Some argv.[i]
        | "--source-type" ->
            i <- i + 1
            args.ImageSourceType <- Some argv.[i]
        | "--visibility" ->
            i <- i + 1
            if args.Command = Some "image" then
                args.ImageVisibility <- Some argv.[i]
                if i + 1 < argv.Length && not (argv.[i + 1].StartsWith("-")) then
                    i <- i + 1
                    args.ImageVisibilityMode <- Some argv.[i]
        | "--spawn" ->
            i <- i + 1
            if args.Command = Some "image" then
                args.ImageSpawn <- Some argv.[i]
        | "--clone" ->
            i <- i + 1
            match args.Command with
            | Some "image" -> args.ImageClone <- Some argv.[i]
            | Some "snapshot" -> args.SnapshotClone <- Some argv.[i]
            | _ -> ()
        | arg when not (arg.StartsWith("-")) -> args.SourceFile <- Some arg
        | arg ->
            if arg.StartsWith("-") && args.Command = Some "session" then
                eprintfn "Unknown option: %s" arg
                eprintfn "Usage: un.fs session [options]"
                Environment.Exit(1)
        i <- i + 1

    args

let printHelp () =
    printfn "Usage: un [options] <source_file>"
    printfn "       un session [options]"
    printfn "       un service [options]"
    printfn "       un service env <action> <service_id> [options]"
    printfn "       un image [options]"
    printfn "       un key [options]"
    printfn "       un languages [--json]"
    printfn ""
    printfn "Execute options:"
    printfn "  -e KEY=VALUE      Set environment variable"
    printfn "  -f FILE           Add input file"
    printfn "  -a                Return artifacts"
    printfn "  -o DIR            Output directory for artifacts"
    printfn "  -n MODE           Network mode (zerotrust/semitrusted)"
    printfn "  -v N              vCPU count (1-8)"
    printfn "  -k KEY            API key"
    printfn ""
    printfn "Session options:"
    printfn "  --list            List active sessions"
    printfn "  --shell NAME      Shell/REPL to use"
    printfn "  --kill ID         Terminate session"
    printfn ""
    printfn "Service options:"
    printfn "  --list            List services"
    printfn "  --name NAME       Service name"
    printfn "  --ports PORTS     Comma-separated ports"
    printfn "  --type TYPE       Service type (minecraft/mumble/teamspeak/source/tcp/udp)"
    printfn "  --bootstrap CMD   Bootstrap command"
    printfn "  --info ID         Get service details"
    printfn "  --logs ID         Get all logs"
    printfn "  --tail ID         Get last 9000 lines"
    printfn "  --freeze ID       Freeze service"
    printfn "  --unfreeze ID     Unfreeze service"
    printfn "  --destroy ID      Destroy service"
    printfn "  --resize ID       Resize service (requires --vcpu N)"
    printfn "  --set-unfreeze-on-demand ID true|false"
    printfn "                    Set unfreeze_on_demand for service"
    printfn "  --execute ID      Execute command in service"
    printfn "  --command CMD     Command to execute (with --execute)"
    printfn "  --dump-bootstrap ID   Dump bootstrap script"
    printfn "  --dump-file FILE      File to save bootstrap (with --dump-bootstrap)"
    printfn "  -e KEY=VALUE      Set vault env var (with --name or env set)"
    printfn "  --env-file FILE   Load vault vars from file"
    printfn ""
    printfn "Service env commands:"
    printfn "  env status ID     Check vault status"
    printfn "  env set ID        Set vault (use -e or --env-file)"
    printfn "  env export ID     Export vault contents"
    printfn "  env delete ID     Delete vault"
    printfn ""
    printfn "Image options:"
    printfn "  -l, --list            List all images"
    printfn "  --info ID             Get image details"
    printfn "  --delete ID           Delete an image"
    printfn "  --lock ID             Lock image to prevent deletion"
    printfn "  --unlock ID           Unlock image"
    printfn "  --publish ID          Publish image (requires --source-type)"
    printfn "  --source-type TYPE    Source type: service or snapshot"
    printfn "  --visibility ID MODE  Set visibility: private, unlisted, or public"
    printfn "  --spawn ID            Spawn new service from image"
    printfn "  --clone ID            Clone an image"
    printfn "  --name NAME           Name for spawned service or cloned image"
    printfn "  --ports PORTS         Ports for spawned service"
    printfn ""
    printfn "Key options:"
    printfn "  --extend          Open browser to extend key"
    printfn "  -k KEY            API key to validate"
    printfn ""
    printfn "Languages options:"
    printfn "  --json            Output as JSON array"

[<EntryPoint>]
let main argv =
    try
        let args = parseArgs argv

        match args.Command with
        | Some "session" -> cmdSession args; 0
        | Some "service" -> cmdService args; 0
        | Some "snapshot" -> cmdSnapshot args; 0
        | Some "image" -> cmdImage args; 0
        | Some "key" -> cmdKey args; 0
        | Some "languages" -> cmdLanguages args; 0
        | _ ->
            match args.SourceFile with
            | Some _ -> cmdExecute args; 0
            | None -> printHelp(); 1
    with ex ->
        eprintfn "%sError: %s%s" red ex.Message reset
        1

Documentation clarifications

Dependencies

C Binary (un1) — requires libcurl and libwebsockets:

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

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

pip install requests  # Python

Execute Code

Run a Script

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

With Environment Variables

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

With Input Files (teleport files into sandbox)

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

Get Compiled Binary (teleport artifacts out)

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

Interactive Sessions

Start a Shell Session

# Default bash shell
./un session

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

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

Session with Network Access

./un session -n semitrusted

Session Auditing (full terminal recording)

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

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

Collect Artifacts from Session

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

Session Persistence (tmux/screen)

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

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

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

List Active Sessions

./un session --list

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

Reconnect to Existing Session

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

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

Terminate a Session

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

Available Shells & REPLs

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

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

API Key Management

Check Key Status

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

# Output:
# Valid: key expires in 30 days

Extend Expired Key

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

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

Authentication

Credentials are loaded in priority order (highest first):

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

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

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

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

Resource Scaling

Set vCPU Count

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

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

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

Live Session Boosting

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

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

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

Session Freeze/Unfreeze

Freeze and Unfreeze Sessions

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

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

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

Persistent Services

Create a Service

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

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

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

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

Manage Services

# List all services
./un service --list

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

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

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

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

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

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

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

# Destroy service
./un service --destroy abc123

Snapshots

List Snapshots

./un snapshot --list

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

Create Session Snapshot

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

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

Create Service Snapshot

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

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

Restore from Snapshot

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

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

Delete Snapshot

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

Images

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

List Images

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

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

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

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

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

Publish Images

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

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

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

Create Services from Images

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

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

Image Protection

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

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

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

Visibility & Sharing

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

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

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

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

Transfer Ownership

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

Usage Reference

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

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

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

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

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

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

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

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

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

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

CLI Inception

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

View All 42 Implementations →

License

PUBLIC DOMAIN - NO LICENSE, NO WARRANTY

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

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

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

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

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

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

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

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

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

Export Vault

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

Import Vault

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