defmodule OpenAgents.Box.Client do
@moduledoc """
Typed `Req` client for the Box Public API v1 at `ascii.dev`.
Every function returns `{:ok, body}` for a `2xx` response and a typed
`{:error, reason}` otherwise. The bearer key comes from the `:box_api_key`
application setting; an absent key fails closed with `:box_not_configured`
before any request leaves the host. Desktop and viewer URLs never pass
through this module: no function requests them, so a token-bearing URL
cannot reach a caller or a log.
One secret does leave here by design. `dispatch_run/5` puts an assignment's
Git credential in the command it sends, because the provider's command API
takes no environment map and a box environment can only be set at create.
That function says why the channel is what it is and what bounds it. No log
this module writes carries that credential: the one path that reads a failed
dispatch response strikes it out by exact match first.
"""
require Logger
@default_base_url "https://ascii.dev/api/box/v1"
@box_id_pattern ~r/^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/
@type body :: map()
@doc "Provisions a new box. The idempotency key makes a retried create safe."
@spec create_box(map(), String.t()) :: {:ok, body()} | {:error, term()}
def create_box(attributes, idempotency_key)
when is_map(attributes) and is_binary(idempotency_key) do
request(:post, "/boxes", json: attributes, headers: [{"idempotency-key", idempotency_key}])
end
@doc "Lists provider Boxes visible to this deployment account."
@spec list_boxes() :: {:ok, body()} | {:error, term()}
def list_boxes, do: list_boxes([])
@spec list_boxes(keyword()) :: {:ok, body()} | {:error, term()}
def list_boxes(options) when is_list(options), do: request(:get, "/boxes", options)
@doc "Reads one box's current state and setup status."
@spec get_box(String.t()) :: {:ok, body()} | {:error, term()}
def get_box(box_id) when is_binary(box_id), do: get_box(box_id, [])
@spec get_box(String.t(), keyword()) :: {:ok, body()} | {:error, term()}
def get_box(box_id, options) when is_binary(box_id) and is_list(options) do
with :ok <- validate_box_id(box_id), do: request(:get, "/boxes/#{box_id}", options)
end
@doc "Stops and archives a box; a snapshot remains available for resume."
@spec stop_box(String.t()) :: {:ok, body()} | {:error, term()}
def stop_box(box_id) when is_binary(box_id), do: stop_box(box_id, [])
@spec stop_box(String.t(), keyword()) :: {:ok, body()} | {:error, term()}
def stop_box(box_id, options) when is_binary(box_id) and is_list(options) do
with :ok <- validate_box_id(box_id), do: request(:post, "/boxes/#{box_id}/stop", options)
end
@doc "Updates provider box metadata used by lifecycle ownership checks."
@spec update_box(String.t(), map()) :: {:ok, body()} | {:error, term()}
def update_box(box_id, attributes) when is_binary(box_id) and is_map(attributes) do
with :ok <- validate_box_id(box_id) do
request(:patch, "/boxes/#{box_id}", json: attributes)
end
end
@doc "Runs one shell command on a box and returns its captured result."
@spec command(String.t(), map()) :: {:ok, body()} | {:error, term()}
def command(box_id, attributes) when is_binary(box_id) and is_map(attributes) do
with :ok <- validate_box_id(box_id) do
request(:post, "/boxes/#{box_id}/commands", json: attributes)
end
end
@doc "Dispatches one detached, idempotent run directory on a box."
@spec dispatch_run(String.t(), String.t(), String.t()) :: {:ok, integer()} | {:error, term()}
def dispatch_run(box_id, run_id, command)
when is_binary(box_id) and is_binary(run_id) and is_binary(command) do
dispatch_run(box_id, run_id, command, nil)
end
@spec dispatch_run(String.t(), String.t(), String.t(), String.t() | nil) ::
{:ok, integer()} | {:error, term()}
def dispatch_run(box_id, run_id, command, run_directory)
when is_binary(box_id) and is_binary(run_id) and is_binary(command) do
with {:ok, body} <-
command(box_id, %{
"command" => dispatch_command(run_id, command, run_directory),
"timeoutSeconds" => 30
}),
{:ok, pid} <- dispatch_pid_logged(body, box_id, run_id) do
{:ok, pid}
end
end
@spec dispatch_run(String.t(), String.t(), String.t(), String.t(), String.t()) ::
{:ok, integer()} | {:error, term()}
def dispatch_run(box_id, run_id, command, run_directory, nil),
do: dispatch_run(box_id, run_id, command, run_directory)
# The credential travels inside the dispatch command, not beside it. The
# provider's `CommandRequest` schema is `command`, `cwd`, `timeoutSeconds`,
# and `detached` — there is no `env`, so an `env` map was accepted with the
# rest of the request and dropped. The wrapper then read
# `$OPENAGENTS_FORGE_TOKEN` under `set -u`, died on the unbound variable, and
# printed no pid, which is the `box_response_invalid` this replaces.
#
# `env` on box *create* is a different endpoint and does work, and it is the
# better channel because the secret never enters a command request. It is not
# available here: an assignment credential is minted per attempt, long after
# its box exists, and `PATCH /boxes/{id}` takes only `name`, `ttlSeconds`,
# and `subdomain`. So this is the documented fallback, and its cost is
# explicit — the credential is in the provider's request body and in whatever
# the provider logs of it. It is not in `box_runs.command`, which holds the
# caller's script and never sees this wrapper, and it is base64 only so that
# a token cannot break the surrounding shell quoting. That is encoding, not
# protection. An assignment credential is short-lived and scoped to one
# branch of one repository, which is what bounds the exposure.
def dispatch_run(box_id, run_id, command, run_directory, credential)
when is_binary(box_id) and is_binary(run_id) and is_binary(command) and
is_binary(run_directory) and is_binary(credential) do
with {:ok, body} <-
command(box_id, %{
"command" => dispatch_command(run_id, command, run_directory, credential),
"timeoutSeconds" => 30
}),
{:ok, pid} <- dispatch_pid_logged(body, box_id, run_id, credential) do
{:ok, pid}
end
end
@doc "Polls one detached run for output and its exit sentinel."
@spec poll_run(String.t(), String.t(), non_neg_integer()) :: {:ok, map()} | {:error, term()}
def poll_run(box_id, run_id, offset)
when is_binary(box_id) and is_binary(run_id) and is_integer(offset) and offset >= 0 do
poll_run(box_id, run_id, offset, nil)
end
@spec poll_run(String.t(), String.t(), non_neg_integer(), String.t() | nil) ::
{:ok, map()} | {:error, term()}
def poll_run(box_id, run_id, offset, run_directory)
when is_binary(box_id) and is_binary(run_id) and is_integer(offset) and offset >= 0 do
with {:ok, body} <-
command(box_id, %{
"command" => poll_command(run_id, offset, run_directory),
"timeoutSeconds" => 30
}) do
{:ok, parse_poll(body)}
end
end
@doc "Probes a detached run directory after an ambiguous dispatch."
@spec probe_run(String.t(), String.t()) :: {:ok, map()} | {:error, term()}
def probe_run(box_id, run_id) when is_binary(box_id) and is_binary(run_id) do
probe_run(box_id, run_id, nil)
end
@spec probe_run(String.t(), String.t(), String.t() | nil) :: {:ok, map()} | {:error, term()}
def probe_run(box_id, run_id, run_directory) when is_binary(box_id) and is_binary(run_id) do
with {:ok, body} <-
command(box_id, %{
"command" => probe_command(run_id, run_directory),
"timeoutSeconds" => 30
}) do
{:ok, parse_probe(body)}
end
end
@doc "Kills the recorded process group for a detached run."
@spec cancel_run(String.t(), String.t()) :: {:ok, body()} | {:error, term()}
def cancel_run(box_id, run_id) when is_binary(box_id) and is_binary(run_id) do
cancel_run(box_id, run_id, nil)
end
@spec cancel_run(String.t(), String.t(), String.t() | nil) :: {:ok, body()} | {:error, term()}
def cancel_run(box_id, run_id, run_directory) when is_binary(box_id) and is_binary(run_id) do
with {:ok, body} <-
command(box_id, %{
"command" => cancel_command(run_id, run_directory),
"timeoutSeconds" => 30
}) do
{:ok, parse_cancel(body)}
end
end
@doc "Whether a string is a well-formed box id."
@spec valid_box_id?(String.t()) :: boolean()
def valid_box_id?(box_id) when is_binary(box_id), do: Regex.match?(@box_id_pattern, box_id)
def valid_box_id?(_box_id), do: false
defp validate_box_id(box_id) do
if valid_box_id?(box_id), do: :ok, else: {:error, :box_not_found}
end
defp dispatch_command(run_id, command, run_directory) do
dispatch_command(run_id, command, run_directory, nil)
end
defp dispatch_command(run_id, command, run_directory, credential) do
root = run_root(run_id, run_directory)
encoded = Base.encode64(command)
with_credential = is_binary(credential)
credential_setup =
if with_credential do
encoded_credential = Base.encode64("https://x:#{credential}@openagents.com\n")
"""
umask 077
printf '%s' '#{encoded_credential}' | base64 -d > "$root/forge-credential"
git config --file="$root/gitconfig" credential.helper "store --file=$root/forge-credential"
"""
else
""
end
launch =
if with_credential do
~s(nohup setsid env GIT_CONFIG_GLOBAL="$root/gitconfig" sh -c 'set +e; sh "$1" > "$2/output.log" 2>&1; status=$?; printf "%s\\n" "$status" > "$2/exit-code"; rm -f "$2/forge-credential" "$2/gitconfig"; exit "$status"' _ "$root/script.sh" "$root" </dev/null >/dev/null 2>&1 &)
else
~s(nohup setsid sh -c 'set +e; sh "$1" > "$2/output.log" 2>&1; status=$?; printf "%s\\n" "$status" > "$2/exit-code"; rm -f "$2/forge-credential" "$2/gitconfig"; exit "$status"' _ "$root/script.sh" "$root" </dev/null >/dev/null 2>&1 &)
end
"""
set -eu
root=#{root}
mkdir -p "$(dirname "$root")"
if ! mkdir "$root" 2>/dev/null; then
printf 'OPENAGENTS_RUN_EXISTS\\n'
exit 73
fi
printf '%s' '#{encoded}' | base64 -d > "$root/script.sh"
chmod 700 "$root/script.sh"
: > "$root/output.log"
#{credential_setup}#{launch}
pid=$!
printf '%s\\n' "$pid" > "$root/pid"
printf '%s\\n' "$pid"
"""
end
defp poll_command(run_id, offset, run_directory) do
root = run_root(run_id, run_directory)
"""
set -eu
root=#{root}
if [ ! -d "$root" ]; then
printf 'OA_PRESENT=0\\n'
exit 0
fi
size=$(wc -c < "$root/output.log" | tr -d ' ')
data=$(if [ "$size" -gt #{offset} ]; then dd if="$root/output.log" bs=1 skip=#{offset} 2>/dev/null | base64 -w0; fi)
printf 'OA_PRESENT=1\\nOA_SIZE=%s\\nOA_DATA=%s\\n' "$size" "$data"
if [ -f "$root/exit-code" ]; then
printf 'OA_EXIT=%s\\n' "$(cat "$root/exit-code")"
fi
if [ -f "$root/pid" ] && kill -0 "$(cat "$root/pid")" 2>/dev/null; then
printf 'OA_ALIVE=1\\n'
else
printf 'OA_ALIVE=0\\n'
fi
"""
end
defp probe_command(run_id, run_directory) do
root = run_root(run_id, run_directory)
"""
set -eu
root=#{root}
if [ ! -d "$root" ]; then
printf 'OA_PRESENT=0\\n'
exit 0
fi
printf 'OA_PRESENT=1\\n'
if [ -f "$root/pid" ]; then
printf 'OA_PID=%s\\n' "$(cat "$root/pid")"
fi
"""
end
defp cancel_command(run_id, run_directory) do
root = run_root(run_id, run_directory)
"""
set -eu
root=#{root}
if [ -f "$root/pid" ]; then
pid=$(cat "$root/pid")
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
cancelled=1
elif pkill -TERM -f -- "$root/script.sh" 2>/dev/null; then
cancelled=1
else
cancelled=0
fi
rm -f "$root/forge-credential" "$root/gitconfig"
printf 'OA_CANCELLED=%s\\n' "$cancelled"
"""
end
defp run_root(run_id, nil), do: "$HOME/.openagents/box-runs/#{run_id}"
defp run_root(_run_id, run_directory), do: run_directory
defp dispatch_pid_logged(body, box_id, run_id, credential \\ nil) do
case dispatch_pid(body) do
{:ok, pid} ->
{:ok, pid}
{:error, :box_response_invalid} = error ->
log_dispatch_refusal(body, box_id, run_id, credential)
error
end
end
# A 2xx whose body carries no pid used to be a bare atom, which said nothing
# about whether the provider refused the request or the wrapper ran and
# failed. Those are opposite diagnoses, and the exit code plus the first line
# of stderr separates them in one look.
#
# This is an operational event, so it stays bounded and carries no raw
# command result: the response's top-level keys, the exit code, output sizes,
# and one truncated stderr line with the assignment credential struck out by
# exact match. The credential is the one secret that can reach this path, and
# this function is the one place that holds it.
defp log_dispatch_refusal(body, box_id, run_id, credential) do
body = if is_map(body), do: body, else: %{}
Logger.warning(
"box_dispatch_response_invalid box_id=#{box_id} run_id=#{run_id} " <>
"keys=#{body |> Map.keys() |> Enum.sort() |> Enum.join(",")} " <>
"exit_code=#{inspect(body["exit_code"] || body["exitCode"])} " <>
"stdout_bytes=#{output_bytes(body["stdout"])} " <>
"stderr_bytes=#{output_bytes(body["stderr"])} " <>
"timed_out=#{inspect(body["timed_out"] || body["timedOut"])} " <>
"stderr_head=#{inspect(stderr_head(body["stderr"], credential))}"
)
end
defp output_bytes(value) when is_binary(value), do: byte_size(value)
defp output_bytes(_value), do: 0
defp stderr_head(stderr, credential) when is_binary(stderr) do
stderr
|> redact(credential)
|> String.split("\n", parts: 2)
|> List.first()
|> String.slice(0, 200)
end
defp stderr_head(_stderr, _credential), do: ""
defp redact(text, credential) when is_binary(credential) and credential != "",
do: String.replace(text, credential, "[redacted]")
defp redact(text, _credential), do: text
defp dispatch_pid(%{"stdout" => output}) when is_binary(output) do
case Regex.run(~r/(?:\A|\n)(\d+)\s*\z/, output) do
[_, pid] -> {:ok, String.to_integer(pid)}
_ -> {:error, :box_response_invalid}
end
end
defp dispatch_pid(%{"pid" => pid}) when is_integer(pid), do: {:ok, pid}
defp dispatch_pid(%{"pid" => pid}) when is_binary(pid), do: integer_or_nil(pid) |> pid_result()
defp dispatch_pid(_body), do: {:error, :box_response_invalid}
defp parse_poll(%{"log_size" => size} = body) when is_integer(size) do
%{
present: Map.get(body, "present", true),
log_size: size,
output: body["output"] || "",
exit_status: body["exit_status"],
alive: Map.get(body, "alive", true)
}
end
defp parse_poll(%{"logSize" => size} = body) when is_integer(size) do
parse_poll(%{
"log_size" => size,
"present" => Map.get(body, "present", true),
"output" => body["output"] || "",
"exit_status" => body["exitStatus"] || body["exitCode"],
"alive" => Map.get(body, "alive", true)
})
end
defp parse_poll(%{"stdout" => output}) when is_binary(output) do
%{
present: marker(output, "OA_PRESENT", "0") == "1",
log_size: marker(output, "OA_SIZE", "0") |> integer_marker(),
output: output_marker(output),
exit_status: integer_or_nil(marker(output, "OA_EXIT", nil)),
alive: marker(output, "OA_ALIVE", "0") == "1"
}
end
defp parse_poll(_body),
do: %{present: false, log_size: 0, output: "", exit_status: nil, alive: false}
defp parse_probe(%{"present" => present} = body) do
%{present: present == true, pid: body["pid"]}
end
defp parse_probe(%{"stdout" => output}) when is_binary(output) do
%{
present: marker(output, "OA_PRESENT", "0") == "1",
pid: integer_or_nil(marker(output, "OA_PID", nil))
}
end
defp parse_probe(_body), do: %{present: false, pid: nil}
defp parse_cancel(%{"stdout" => output} = body) when is_binary(output) do
if String.contains?(output, "OA_CANCELLED=0") do
Map.put(body, "cancelled", false)
else
Map.put(body, "cancelled", true)
end
end
defp parse_cancel(body), do: body
defp output_marker(output) do
case Regex.run(~r/OA_DATA=([A-Za-z0-9+\/=]*)/, output) do
[_, encoded] ->
case Base.decode64(encoded) do
{:ok, decoded} -> decoded
:error -> ""
end
_missing ->
""
end
end
defp marker(output, key, default) do
case Regex.run(~r/#{key}=([^\n]*)/, output) do
[_, value] -> value
_missing -> default
end
end
defp integer_marker(value), do: String.to_integer(value || "0")
defp integer_or_nil(nil), do: nil
defp integer_or_nil(value) do
case Integer.parse(value) do
{integer, ""} -> integer
_invalid -> nil
end
end
defp pid_result(nil), do: {:error, :box_response_invalid}
defp pid_result(pid), do: {:ok, pid}
defp request(method, api_path, options) do
with {:ok, api_key} <- api_key() do
settings = Application.get_env(:openagents, :box_api, [])
base_url = settings[:base_url] || @default_base_url
request_options =
(settings[:request_options] || [])
|> Keyword.put_new(:receive_timeout, settings[:receive_timeout_ms] || 630_000)
|> Keyword.merge(options)
|> Keyword.put(:auth, {:bearer, api_key})
|> Keyword.put_new(:retry, retry_policy(method))
|> Keyword.put_new(:max_retries, 2)
|> Keyword.put_new(:retry_log_level, false)
case Req.request([method: method, url: base_url <> api_path] ++ request_options) do
{:ok, %Req.Response{status: status, body: body}}
when status in 200..299 and is_map(body) ->
{:ok, body}
{:ok, %Req.Response{status: status}} when status in 200..299 ->
{:error, :box_response_invalid}
{:ok, %Req.Response{status: 401}} ->
{:error, :box_unauthorized}
{:ok, %Req.Response{status: status}} when status in [402, 403] ->
{:error, :box_billing_required}
{:ok, %Req.Response{status: 404}} ->
{:error, :box_not_found}
{:ok, %Req.Response{status: 429}} ->
{:error, :box_rate_limited}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:box_request_refused, status, error_code(body)}}
{:error, _transport} ->
{:error, :box_unreachable}
end
end
end
# Reads retry transparently; a command is not replayed because a timed-out
# command may still be running on the box.
defp retry_policy(:get), do: :safe_transient
defp retry_policy(_method), do: false
defp api_key do
case Application.fetch_env(:openagents, :box_api_key) do
{:ok, key} when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
_missing -> {:error, :box_not_configured}
end
end
defp error_code(%{"code" => code}) when is_binary(code), do: code
defp error_code(_body), do: "unknown"
end