Elixir SDK for Pocketenv
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