Elixir SDK for Pocketenv
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 448 lines 14 kB view raw
1defmodule Pocketenv.API do 2 @moduledoc false 3 # Internal HTTP layer. Consumers should use the `Pocketenv` module and 4 # pipe on `%Sandbox{}` structs. This module is not part of the public API. 5 6 alias Pocketenv.Client 7 alias Pocketenv.Crypto 8 alias Sandbox.Types.{Backup, ExecResult, Port, Profile, Secret, SshKey, TailscaleAuthKey} 9 10 @default_base "at://did:plc:aturpi2ls3yvsmhc6wybomun/io.pocketenv.sandbox/openclaw" 11 12 # --------------------------------------------------------------------------- 13 # Sandbox CRUD 14 # --------------------------------------------------------------------------- 15 16 def create_sandbox(name, opts \\ []) do 17 body = 18 %{ 19 "name" => name, 20 "base" => Keyword.get(opts, :base, @default_base), 21 "provider" => opts |> Keyword.get(:provider, :cloudflare) |> to_string() 22 } 23 |> maybe_put("repo", Keyword.get(opts, :repo)) 24 |> maybe_put("keepAlive", Keyword.get(opts, :keep_alive)) 25 26 case Client.post("/xrpc/io.pocketenv.sandbox.createSandbox", body, take_token(opts)) do 27 {:ok, data} -> {:ok, Sandbox.from_map(data)} 28 {:error, _} = err -> err 29 end 30 end 31 32 def start_sandbox(id, opts \\ []) do 33 body = 34 %{} 35 |> maybe_put("repo", Keyword.get(opts, :repo)) 36 |> maybe_put("keepAlive", Keyword.get(opts, :keep_alive)) 37 38 Client.post( 39 "/xrpc/io.pocketenv.sandbox.startSandbox", 40 body, 41 take_token(opts) ++ [params: %{"id" => id}] 42 ) 43 end 44 45 def stop_sandbox(id, opts \\ []) do 46 Client.post( 47 "/xrpc/io.pocketenv.sandbox.stopSandbox", 48 nil, 49 take_token(opts) ++ [params: %{"id" => id}] 50 ) 51 end 52 53 def delete_sandbox(id, opts \\ []) do 54 Client.post( 55 "/xrpc/io.pocketenv.sandbox.deleteSandbox", 56 nil, 57 take_token(opts) ++ [params: %{"id" => id}] 58 ) 59 end 60 61 # --------------------------------------------------------------------------- 62 # Sandbox queries 63 # --------------------------------------------------------------------------- 64 65 def get_sandbox(id, opts \\ []) do 66 case Client.get( 67 "/xrpc/io.pocketenv.sandbox.getSandbox", 68 take_token(opts) ++ [params: %{"id" => id}] 69 ) do 70 {:ok, %{"sandbox" => nil}} -> {:ok, nil} 71 {:ok, %{"sandbox" => data}} -> {:ok, Sandbox.from_map(data)} 72 {:ok, data} when is_map(data) -> {:ok, Sandbox.from_map(data)} 73 {:error, _} = err -> err 74 end 75 end 76 77 def list_sandboxes(opts \\ []) do 78 params = %{ 79 "limit" => Keyword.get(opts, :limit, 30), 80 "offset" => Keyword.get(opts, :offset, 0) 81 } 82 83 case Client.get( 84 "/xrpc/io.pocketenv.sandbox.getSandboxes", 85 take_token(opts) ++ [params: params] 86 ) do 87 {:ok, %{"sandboxes" => items, "total" => total}} -> 88 {:ok, {Enum.map(items, &Sandbox.from_map/1), total}} 89 90 {:error, _} = err -> 91 err 92 end 93 end 94 95 def list_sandboxes_by_actor(did, opts \\ []) do 96 params = %{ 97 "did" => did, 98 "limit" => Keyword.get(opts, :limit, 30), 99 "offset" => Keyword.get(opts, :offset, 0) 100 } 101 102 case Client.get( 103 "/xrpc/io.pocketenv.actor.getActorSandboxes", 104 take_token(opts) ++ [params: params] 105 ) do 106 {:ok, %{"sandboxes" => items, "total" => total}} -> 107 {:ok, {Enum.map(items, &Sandbox.from_map/1), total}} 108 109 {:error, _} = err -> 110 err 111 end 112 end 113 114 def wait_until_running(id, opts \\ []) do 115 timeout_ms = Keyword.get(opts, :timeout_ms, 60_000) 116 interval_ms = Keyword.get(opts, :interval_ms, 2_000) 117 deadline = System.monotonic_time(:millisecond) + timeout_ms 118 do_wait(id, opts, deadline, interval_ms) 119 end 120 121 # --------------------------------------------------------------------------- 122 # Exec 123 # --------------------------------------------------------------------------- 124 125 def exec(id, cmd, args \\ [], opts \\ []) do 126 command = Enum.join([cmd | args], " ") 127 128 case Client.post( 129 "/xrpc/io.pocketenv.sandbox.exec", 130 %{"command" => command}, 131 take_token(opts) ++ [params: %{"id" => id}] 132 ) do 133 {:ok, data} -> {:ok, ExecResult.from_map(data)} 134 {:error, _} = err -> err 135 end 136 end 137 138 # --------------------------------------------------------------------------- 139 # Ports 140 # --------------------------------------------------------------------------- 141 142 def expose_port(id, port, opts \\ []) do 143 body = 144 %{"port" => port} 145 |> maybe_put("description", Keyword.get(opts, :description)) 146 147 case Client.post( 148 "/xrpc/io.pocketenv.sandbox.exposePort", 149 body, 150 take_token(opts) ++ [params: %{"id" => id}] 151 ) do 152 {:ok, %{"previewUrl" => url}} -> {:ok, url} 153 {:ok, _} -> {:ok, nil} 154 {:error, _} = err -> err 155 end 156 end 157 158 def unexpose_port(id, port, opts \\ []) do 159 Client.post( 160 "/xrpc/io.pocketenv.sandbox.unexposePort", 161 %{"port" => port}, 162 take_token(opts) ++ [params: %{"id" => id}] 163 ) 164 end 165 166 def list_ports(id, opts \\ []) do 167 case Client.get( 168 "/xrpc/io.pocketenv.sandbox.getExposedPorts", 169 take_token(opts) ++ [params: %{"id" => id}] 170 ) do 171 {:ok, %{"ports" => ports}} -> {:ok, Enum.map(ports, &Port.from_map/1)} 172 {:error, _} = err -> err 173 end 174 end 175 176 # --------------------------------------------------------------------------- 177 # VS Code 178 # --------------------------------------------------------------------------- 179 180 def expose_vscode(id, opts \\ []) do 181 case Client.post( 182 "/xrpc/io.pocketenv.sandbox.exposeVscode", 183 nil, 184 take_token(opts) ++ [params: %{"id" => id}] 185 ) do 186 {:ok, %{"previewUrl" => url}} -> {:ok, url} 187 {:ok, _} -> {:ok, nil} 188 {:error, _} = err -> err 189 end 190 end 191 192 # --------------------------------------------------------------------------- 193 # Actor / profile 194 # --------------------------------------------------------------------------- 195 196 def me(opts \\ []) do 197 case Client.get("/xrpc/io.pocketenv.actor.getProfile", take_token(opts)) do 198 {:ok, data} -> {:ok, Profile.from_map(data)} 199 {:error, _} = err -> err 200 end 201 end 202 203 def get_profile(did, opts \\ []) do 204 case Client.get( 205 "/xrpc/io.pocketenv.actor.getProfile", 206 take_token(opts) ++ [params: %{"did" => did}] 207 ) do 208 {:ok, data} -> {:ok, Profile.from_map(data)} 209 {:error, _} = err -> err 210 end 211 end 212 213 # --------------------------------------------------------------------------- 214 # Secrets 215 # --------------------------------------------------------------------------- 216 217 def list_secrets(sandbox_id, opts \\ []) do 218 params = %{ 219 "sandboxId" => sandbox_id, 220 "offset" => Keyword.get(opts, :offset, 0), 221 "limit" => Keyword.get(opts, :limit, 100) 222 } 223 224 case Client.get( 225 "/xrpc/io.pocketenv.secret.getSecrets", 226 take_token(opts) ++ [params: params] 227 ) do 228 {:ok, %{"secrets" => items}} -> {:ok, Enum.map(items, &Secret.from_map/1)} 229 {:error, _} = err -> err 230 end 231 end 232 233 def add_secret(sandbox_id, name, value, opts \\ []) do 234 {:ok, encrypted} = Crypto.encrypt(value) 235 236 Client.post( 237 "/xrpc/io.pocketenv.secret.addSecret", 238 %{"secret" => %{"sandboxId" => sandbox_id, "name" => name, "value" => encrypted}}, 239 take_token(opts) 240 ) 241 end 242 243 def delete_secret(id, opts \\ []) do 244 Client.post( 245 "/xrpc/io.pocketenv.secret.deleteSecret", 246 nil, 247 take_token(opts) ++ [params: %{"id" => id}] 248 ) 249 end 250 251 # --------------------------------------------------------------------------- 252 # SSH Keys 253 # --------------------------------------------------------------------------- 254 255 def get_ssh_keys(sandbox_id, opts \\ []) do 256 case Client.get( 257 "/xrpc/io.pocketenv.sandbox.getSshKeys", 258 take_token(opts) ++ [params: %{"id" => sandbox_id}] 259 ) do 260 {:ok, data} -> {:ok, SshKey.from_map(data)} 261 {:error, _} = err -> err 262 end 263 end 264 265 def put_ssh_keys(sandbox_id, private_key, public_key, opts \\ []) do 266 {:ok, encrypted_private_key} = Crypto.encrypt(private_key) 267 redacted = redact_ssh_private_key(private_key) 268 269 Client.post( 270 "/xrpc/io.pocketenv.sandbox.putSshKeys", 271 %{ 272 "id" => sandbox_id, 273 "privateKey" => encrypted_private_key, 274 "publicKey" => public_key, 275 "redacted" => redacted 276 }, 277 take_token(opts) 278 ) 279 end 280 281 # --------------------------------------------------------------------------- 282 # Tailscale 283 # --------------------------------------------------------------------------- 284 285 def get_tailscale_auth_key(sandbox_id, opts \\ []) do 286 case Client.get( 287 "/xrpc/io.pocketenv.sandbox.getTailscaleAuthKey", 288 take_token(opts) ++ [params: %{"id" => sandbox_id}] 289 ) do 290 {:ok, data} -> {:ok, TailscaleAuthKey.from_map(data)} 291 {:error, _} = err -> err 292 end 293 end 294 295 def put_tailscale_auth_key(sandbox_id, auth_key, opts \\ []) do 296 unless String.starts_with?(auth_key, "tskey-auth-") do 297 raise ArgumentError, "Tailscale auth key must start with \"tskey-auth-\"" 298 end 299 300 {:ok, encrypted} = Crypto.encrypt(auth_key) 301 redacted = redact_tailscale_key(auth_key) 302 303 Client.post( 304 "/xrpc/io.pocketenv.sandbox.putTailscaleAuthKey", 305 %{"id" => sandbox_id, "authKey" => encrypted, "redacted" => redacted}, 306 take_token(opts) 307 ) 308 end 309 310 # --------------------------------------------------------------------------- 311 # Backups 312 # --------------------------------------------------------------------------- 313 314 def create_backup(sandbox_id, directory, opts \\ []) do 315 body = 316 %{"directory" => directory} 317 |> maybe_put("description", Keyword.get(opts, :description)) 318 |> maybe_put("ttl", Keyword.get(opts, :ttl)) 319 320 Client.post( 321 "/xrpc/io.pocketenv.sandbox.createBackup", 322 body, 323 take_token(opts) ++ [params: %{"id" => sandbox_id}] 324 ) 325 end 326 327 def list_backups(sandbox_id, opts \\ []) do 328 case Client.get( 329 "/xrpc/io.pocketenv.sandbox.getBackups", 330 take_token(opts) ++ [params: %{"id" => sandbox_id}] 331 ) do 332 {:ok, %{"backups" => items}} -> {:ok, Enum.map(items, &Backup.from_map/1)} 333 {:error, _} = err -> err 334 end 335 end 336 337 def restore_backup(backup_id, opts \\ []) do 338 Client.post( 339 "/xrpc/io.pocketenv.sandbox.restoreBackup", 340 %{"backupId" => backup_id}, 341 take_token(opts) 342 ) 343 end 344 345 # --------------------------------------------------------------------------- 346 # Copy 347 # --------------------------------------------------------------------------- 348 349 def push_directory(sandbox_id, directory_path, opts \\ []) do 350 case Client.post( 351 "/xrpc/io.pocketenv.sandbox.pushDirectory", 352 %{"sandboxId" => sandbox_id, "directoryPath" => directory_path}, 353 take_token(opts) 354 ) do 355 {:ok, %{"uuid" => uuid}} -> {:ok, uuid} 356 {:error, _} = err -> err 357 end 358 end 359 360 def pull_directory(sandbox_id, uuid, directory_path, opts \\ []) do 361 Client.post( 362 "/xrpc/io.pocketenv.sandbox.pullDirectory", 363 %{"uuid" => uuid, "sandboxId" => sandbox_id, "directoryPath" => directory_path}, 364 take_token(opts) 365 ) 366 end 367 368 # --------------------------------------------------------------------------- 369 # Private helpers 370 # --------------------------------------------------------------------------- 371 372 defp redact_ssh_private_key(private_key) do 373 header = "-----BEGIN OPENSSH PRIVATE KEY-----" 374 footer = "-----END OPENSSH PRIVATE KEY-----" 375 376 case {String.contains?(private_key, header), String.contains?(private_key, footer)} do 377 {true, true} -> 378 header_end = :binary.match(private_key, header) |> elem(0) 379 body_start = header_end + byte_size(header) 380 footer_start = :binary.match(private_key, footer) |> elem(0) 381 body = binary_part(private_key, body_start, footer_start - body_start) 382 383 chars = String.graphemes(body) 384 385 non_newline_indices = 386 chars 387 |> Enum.with_index() 388 |> Enum.filter(fn {c, _i} -> c != "\n" end) 389 |> Enum.map(fn {_c, i} -> i end) 390 391 masked_chars = 392 if length(non_newline_indices) > 15 do 393 middle_indices = Enum.slice(non_newline_indices, 10, length(non_newline_indices) - 15) 394 mask_set = MapSet.new(middle_indices) 395 396 chars 397 |> Enum.with_index() 398 |> Enum.map(fn {c, i} -> if MapSet.member?(mask_set, i), do: "*", else: c end) 399 else 400 chars 401 end 402 403 masked_body = Enum.join(masked_chars) 404 405 "#{header}#{masked_body}#{footer}" 406 |> String.replace("\n", "\\n") 407 408 _ -> 409 String.replace(private_key, "\n", "\\n") 410 end 411 end 412 413 defp redact_tailscale_key(auth_key) when byte_size(auth_key) > 14 do 414 String.slice(auth_key, 0, 11) <> 415 String.duplicate("*", byte_size(auth_key) - 14) <> 416 String.slice(auth_key, -3, 3) 417 end 418 419 defp redact_tailscale_key(auth_key), do: auth_key 420 421 defp do_wait(id, opts, deadline, interval_ms) do 422 if System.monotonic_time(:millisecond) >= deadline do 423 {:error, :timeout} 424 else 425 case get_sandbox(id, opts) do 426 {:ok, %Sandbox{status: :running} = sandbox} -> 427 {:ok, sandbox} 428 429 {:ok, _} -> 430 Process.sleep(interval_ms) 431 do_wait(id, opts, deadline, interval_ms) 432 433 {:error, _} = err -> 434 err 435 end 436 end 437 end 438 439 defp take_token(opts) do 440 case Keyword.fetch(opts, :token) do 441 {:ok, token} -> [token: token] 442 :error -> [] 443 end 444 end 445 446 defp maybe_put(map, _key, nil), do: map 447 defp maybe_put(map, key, value), do: Map.put(map, key, value) 448end