clients/ruby: initial Ruby SDK for clawdforge

This commit is contained in:
Kayos 2026-04-28 22:34:01 -07:00
parent 90e158f2fe
commit b1d6e3f697
12 changed files with 981 additions and 0 deletions

5
clients/ruby/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
/.bundle/
/vendor/
/pkg/
*.gem
Gemfile.lock

5
clients/ruby/Gemfile Normal file
View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
source "https://rubygems.org"
gemspec

143
clients/ruby/README.md Normal file
View file

@ -0,0 +1,143 @@
# clawdforge — Ruby SDK
A small, dependency-free Ruby client for the [clawdforge](../../) HTTP service.
clawdforge is a LAN-only HTTP service that wraps `claude -p` subprocess calls
behind a bearer-token-gated REST API. This SDK is a thin idiomatic wrapper
around that API.
- **Stdlib only at runtime**`net/http` + `json`. No `httparty`, no `faraday`.
- **Ruby 3.0+** — keyword args, frozen string literals.
- **Five public methods**`healthz`, `run`, `upload_file`, `create_token`,
`list_tokens`, `revoke_token`. That's it.
## Install
From a path checkout (typical inside the clawdforge monorepo):
```ruby
# Gemfile
gem "clawdforge", path: "clients/ruby"
```
Or from a built gem:
```sh
cd clients/ruby
gem build clawdforge.gemspec
gem install ./clawdforge-0.1.0.gem
```
## Quickstart
```ruby
require "clawdforge"
forge = Clawdforge::Client.new(
base_url: "http://localhost:8800",
token: ENV.fetch("CLAWDFORGE_TOKEN"),
)
# 1) liveness
health = forge.healthz
puts health["claude_version"]
# 2) run a prompt
result = forge.run(
prompt: 'Reply with JSON: {"hello": "world"}',
model: "sonnet", # optional, default "sonnet"
system: "Be terse.", # optional
timeout_secs: 60, # optional, default 120, server range 5..600
)
puts result.duration_ms
puts result.stop_reason
# `result.result` is parsed JSON (Hash) when the model returned valid JSON,
# otherwise the raw String.
puts result.result["hello"]
# 3) upload a file, then attach it to a /run call
ft = forge.upload_file("./recipe.png", ttl_secs: 3600)
forge.run(prompt: "extract recipe data", files: [ft.file_token])
```
## Public API
### `Clawdforge::Client.new(base_url:, token:, **opts)`
| keyword | type | default | meaning |
|-----------------------|---------|---------|--------------------------------------------------|
| `base_url:` | String | — | e.g. `"http://localhost:8800"`. Required. |
| `token:` | String | — | bearer token. Required. |
| `default_model:` | String | `"sonnet"` | model used when `run` doesn't supply one. |
| `default_timeout:` | Integer | `120` | `timeout_secs` used when `run` doesn't supply. |
| `http_timeout_margin:`| Integer | `30` | added to subprocess timeout for HTTP timeout. |
| `http_client:` | Net::HTTP | `nil` | inject a pre-built Net::HTTP (mostly for tests). |
### `#healthz`
Returns a Hash: `{"ok" => Bool, "claude_present" => Bool, "claude_version" => String | nil}`.
### `#run(prompt:, model: nil, system: nil, files: nil, timeout_secs: nil) -> RunResult`
- `prompt` (String, required, non-empty)
- `model` (String, optional)
- `system` (String, optional)
- `files` (Array<String>, optional) — file tokens from `upload_file`
- `timeout_secs` (Integer, optional, server range 5..600)
`RunResult` is a Struct with reader methods:
- `ok` (Bool)
- `result` (Hash | String) — parsed JSON if the model returned valid JSON, raw String otherwise
- `duration_ms` (Integer)
- `stop_reason` (String | nil)
### `#upload_file(path, ttl_secs: 3600, filename: nil, content_type: "application/octet-stream") -> FileToken`
Streams the file in 1 MiB chunks via `IO.copy_stream`. `FileToken` carries
`file_token`, `ttl_secs`, `size`.
### Admin methods (admin-bootstrap-token gated)
- `#create_token(name, ip_cidrs: nil) -> AppToken`
- `#list_tokens -> Array<AppToken>`
- `#revoke_token(name) -> true` (raises `APIError(404)` if not found)
`AppToken`'s `token` field holds the plaintext bearer **only** at create
time; on list responses it is `nil` (the server stores only a sha256 hash).
## Errors
```
Clawdforge::Error
├── Clawdforge::TransportError # connection refused, DNS, TCP timeout, TLS, …
└── Clawdforge::APIError # 4xx / 5xx, exposes #status and #body
└── Clawdforge::AuthError # 401 / 403
```
Catch `Clawdforge::Error` to handle the whole family. On `APIError`,
inspect `.status` (Integer) and `.body` (Hash if JSON, String otherwise).
```ruby
begin
forge.run(prompt: "hi", timeout_secs: 5)
rescue Clawdforge::AuthError => e
warn "bad token: #{e.message}"
rescue Clawdforge::APIError => e
warn "server said #{e.status}: #{e.body.inspect}"
rescue Clawdforge::TransportError => e
warn "couldn't reach forge: #{e.message}"
end
```
No retry logic is built in. clawdforge runs are not idempotent (they spawn
`claude -p`), so retry policy belongs with the caller.
## Development
```sh
bundle install
bundle exec rake test # runs Minitest with WebMock
bundle exec rake build # builds clawdforge-X.Y.Z.gem
```
## License
MIT.

17
clients/ruby/Rakefile Normal file
View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
require "rake/testtask"
Rake::TestTask.new(:test) do |t|
t.libs << "lib"
t.libs << "test"
t.test_files = FileList["test/test_*.rb"]
t.warning = false
end
desc "Build the gem"
task :build do
sh "gem build clawdforge.gemspec"
end
task default: :test

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require_relative "lib/clawdforge/version"
Gem::Specification.new do |spec|
spec.name = "clawdforge"
spec.version = Clawdforge::VERSION
spec.authors = ["Kayos"]
spec.email = ["kayos@sulkta.com"]
spec.summary = "Ruby SDK for clawdforge — a LAN HTTP service that wraps `claude -p`."
spec.description = <<~DESC
A small, dependency-free Ruby client for the clawdforge HTTP service.
Talks to /healthz, /run, /files, and /admin/tokens with a bearer token.
Built on Net::HTTP and the JSON stdlib no external runtime gems.
DESC
spec.homepage = "https://github.com/Sulkta-Coop/clawdforge"
spec.license = "MIT"
spec.required_ruby_version = ">= 3.0.0"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["rubygems_mfa_required"] = "true"
spec.files = Dir[
"lib/**/*.rb",
"README.md",
"clawdforge.gemspec",
]
spec.require_paths = ["lib"]
spec.add_development_dependency "minitest", "~> 5.18"
spec.add_development_dependency "rake", "~> 13.0"
spec.add_development_dependency "webmock", "~> 3.18"
end

View file

@ -0,0 +1,44 @@
# frozen_string_literal: true
# Basic usage example for the clawdforge Ruby SDK.
#
# Run with:
# CLAWDFORGE_URL=http://localhost:8800 \
# CLAWDFORGE_TOKEN=cf_xxx \
# ruby -Ilib examples/basic.rb
require "clawdforge"
base_url = ENV.fetch("CLAWDFORGE_URL", "http://localhost:8800")
token = ENV.fetch("CLAWDFORGE_TOKEN")
forge = Clawdforge::Client.new(base_url: base_url, token: token)
# 1) Healthz — verify the service is up and `claude` is installed.
health = forge.healthz
puts "claude_present: #{health["claude_present"]}"
puts "claude_version: #{health["claude_version"]}"
# 2) Run a prompt that asks for JSON. `result` will be a parsed Hash.
result = forge.run(
prompt: 'Reply with JSON: {"hello": "world"}',
model: "sonnet",
system: "Be terse. Reply with JSON only.",
timeout_secs: 60,
)
puts "duration_ms: #{result.duration_ms}"
puts "stop_reason: #{result.stop_reason}"
puts "result: #{result.result.inspect}"
# 3) Upload a file, then reference it from a /run call.
if File.file?("./recipe.png")
ft = forge.upload_file("./recipe.png", ttl_secs: 3600)
puts "uploaded #{ft.size} bytes; token=#{ft.file_token} ttl=#{ft.ttl_secs}s"
vision = forge.run(
prompt: "extract recipe data as JSON",
files: [ft.file_token],
timeout_secs: 120,
)
puts vision.result.inspect
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Clawdforge — Ruby SDK for the clawdforge HTTP service.
#
# The clawdforge service wraps `claude -p` subprocess calls behind a bearer-
# token-gated REST API. This SDK is a thin idiomatic wrapper over that API.
#
# Quickstart:
#
# require "clawdforge"
# forge = Clawdforge::Client.new(base_url: "http://localhost:8800", token: "cf_...")
# r = forge.run(prompt: 'Reply with JSON: {"hello": "world"}')
# r.result # => {"hello" => "world"}
module Clawdforge
end
require_relative "clawdforge/version"
require_relative "clawdforge/errors"
require_relative "clawdforge/models"
require_relative "clawdforge/client"

View file

@ -0,0 +1,283 @@
# frozen_string_literal: true
require "json"
require "net/http"
require "securerandom"
require "uri"
require_relative "errors"
require_relative "models"
module Clawdforge
# Sync HTTP client for the clawdforge service.
#
# No retry logic is built in. Wrap calls in your own retry layer if you
# need it — clawdforge runs are not idempotent (they spawn `claude -p`),
# so retries belong with the caller who knows the prompt's semantics.
#
# @example
# forge = Clawdforge::Client.new(base_url: "http://localhost:8800", token: "cf_...")
# r = forge.run(prompt: 'Reply with JSON: {"hi": "ok"}')
# r.result # => {"hi" => "ok"}
class Client
DEFAULT_MODEL = "sonnet"
DEFAULT_RUN_TIMEOUT_SECS = 120
# HTTP timeout = run's subprocess timeout + this margin so we don't bail
# while clawdforge is still doing legitimate work for us.
HTTP_TIMEOUT_MARGIN_SECS = 30
HEALTHZ_TIMEOUT_SECS = 10
ADMIN_TIMEOUT_SECS = 10
attr_reader :base_url, :default_model, :default_timeout, :http_timeout_margin
# @param base_url [String] e.g. "http://localhost:8800". Trailing slash is stripped.
# @param token [String] bearer token. App token (`cf_...`) for `/run` and
# `/files`; admin bootstrap token for `/admin/*`.
# @param default_model [String] model passed to `/run` when caller doesn't
# supply one. Defaults to "sonnet".
# @param default_timeout [Integer] `timeout_secs` passed to `/run` when
# caller doesn't supply one. Defaults to 120.
# @param http_timeout_margin [Integer] seconds added to the subprocess
# timeout to derive the HTTP-level timeout. Defaults to 30.
# @param http_client [Net::HTTP, nil] optional pre-built Net::HTTP. Mostly
# useful for tests.
def initialize(base_url:, token:,
default_model: DEFAULT_MODEL,
default_timeout: DEFAULT_RUN_TIMEOUT_SECS,
http_timeout_margin: HTTP_TIMEOUT_MARGIN_SECS,
http_client: nil)
raise ArgumentError, "base_url is required" if base_url.nil? || base_url.empty?
raise ArgumentError, "token is required" if token.nil? || token.empty?
@base_url = base_url.sub(%r{/+\z}, "")
@token = token
@default_model = default_model
@default_timeout = default_timeout
@http_timeout_margin = http_timeout_margin
@http_client = http_client
@uri = URI.parse(@base_url)
end
# GET /healthz — liveness + `claude --version` smoke check.
#
# @return [Hash] `{"ok" => true, "claude_present" => Boolean,
# "claude_version" => String | nil}`
def healthz
request(:get, "/healthz", timeout: HEALTHZ_TIMEOUT_SECS)
end
# POST /run — run a prompt through `claude -p`.
#
# @param prompt [String] required, non-empty.
# @param model [String, nil] override; defaults to `default_model`.
# @param system [String, nil] optional system prompt.
# @param files [Array<String>, nil] `ff_...` file tokens previously
# returned from `upload_file`.
# @param timeout_secs [Integer, nil] per-run subprocess timeout (5..600).
# Defaults to `default_timeout`.
# @return [RunResult]
# @raise [APIError] on non-2xx; 502 carries `error`/`stderr`/etc. in `body`.
# @raise [AuthError] on 401/403.
# @raise [TransportError] on connection-level failures.
# @raise [ArgumentError] on empty prompt.
def run(prompt:, model: nil, system: nil, files: nil, timeout_secs: nil)
raise ArgumentError, "prompt must be non-empty" if prompt.nil? || prompt.empty?
body = { "prompt" => prompt, "model" => model || @default_model }
body["system"] = system unless system.nil?
body["files"] = Array(files) if files && !files.empty?
body["timeout_secs"] = timeout_secs unless timeout_secs.nil?
effective_run_timeout = timeout_secs || @default_timeout
http_timeout = effective_run_timeout + @http_timeout_margin
payload = request(:post, "/run", json_body: body, timeout: http_timeout)
raise Error, "unexpected /run response type: #{payload.class}" unless payload.is_a?(Hash)
RunResult.from_response(payload)
end
# POST /files — upload a file, get back an `ff_...` token.
#
# Streams the file from disk in 1 MiB chunks via IO.copy_stream so big
# uploads don't load the whole payload into memory.
#
# @param path [String] filesystem path to the file to upload.
# @param ttl_secs [Integer] server-side TTL (60..86400). Defaults to 3600.
# @param filename [String, nil] override the filename advertised to the
# server. Defaults to `File.basename(path)`.
# @param content_type [String] MIME type. Defaults to "application/octet-stream".
# @return [FileToken]
def upload_file(path, ttl_secs: 3600, filename: nil, content_type: "application/octet-stream")
raise ArgumentError, "no such file: #{path}" unless File.file?(path)
advertised_name = filename || File.basename(path)
boundary = "----ClawdforgeRB#{SecureRandom.hex(12)}"
File.open(path, "rb") do |io|
body_io = build_multipart(io, advertised_name, content_type, ttl_secs, boundary)
headers = {
"Content-Type" => "multipart/form-data; boundary=#{boundary}",
"Content-Length" => body_io.size.to_s,
}
payload = request(
:post, "/files",
raw_body: body_io,
extra_headers: headers,
timeout: @default_timeout + @http_timeout_margin,
)
unless payload.is_a?(Hash)
raise Error, "unexpected /files response type: #{payload.class}"
end
FileToken.from_response(payload)
end
end
# POST /admin/tokens — mint a per-app token. Admin-bootstrap-token gated.
#
# The returned AppToken's `token` field holds the plaintext bearer; store
# it immediately, the server only keeps a sha256 hash.
#
# @param name [String] app name (lowercase alnum + `_-`).
# @param ip_cidrs [Array<String>, nil] per-app IP allowlist.
# @return [AppToken]
def create_token(name, ip_cidrs: nil)
body = { "name" => name, "ip_cidrs" => Array(ip_cidrs) }
payload = request(:post, "/admin/tokens", json_body: body, timeout: ADMIN_TIMEOUT_SECS)
raise Error, "unexpected /admin/tokens response" unless payload.is_a?(Hash)
AppToken.from_create_response(payload)
end
# GET /admin/tokens — list all tokens.
# @return [Array<AppToken>]
def list_tokens
payload = request(:get, "/admin/tokens", timeout: ADMIN_TIMEOUT_SECS)
unless payload.is_a?(Hash) && payload["tokens"].is_a?(Array)
raise Error, "unexpected /admin/tokens response"
end
payload["tokens"].map { |row| AppToken.from_list_row(row) }
end
# DELETE /admin/tokens/<name> — revoke a token.
# @return [Boolean] true on success. Raises APIError(404) if missing.
def revoke_token(name)
payload = request(:delete, "/admin/tokens/#{URI.encode_www_form_component(name)}",
timeout: ADMIN_TIMEOUT_SECS)
payload.is_a?(Hash) ? !!payload.fetch("ok", true) : true
end
private
# Build a multipart/form-data body containing the `ttl_secs` form field
# and the `file` part with the uploaded bytes. Returns a StringIO whose
# contents include the entire body (so `Content-Length` is exact).
def build_multipart(file_io, filename, content_type, ttl_secs, boundary)
out = StringIO.new
out.binmode
out << "--#{boundary}\r\n"
out << %(Content-Disposition: form-data; name="ttl_secs"\r\n\r\n)
out << ttl_secs.to_s
out << "\r\n"
out << "--#{boundary}\r\n"
out << %(Content-Disposition: form-data; name="file"; filename="#{quote_filename(filename)}"\r\n)
out << "Content-Type: #{content_type}\r\n\r\n"
IO.copy_stream(file_io, out)
out << "\r\n"
out << "--#{boundary}--\r\n"
out.rewind
out
end
def quote_filename(name)
# RFC 7578 §4.2 — escape backslashes and double quotes; strip CR/LF.
name.to_s.gsub("\\", "\\\\\\\\").gsub('"', '\\"').delete("\r\n")
end
def request(method, path, json_body: nil, raw_body: nil, extra_headers: nil, timeout: nil)
uri = URI.join("#{@base_url}/", path.sub(%r{\A/}, ""))
req = build_request(method, uri, json_body: json_body, raw_body: raw_body,
extra_headers: extra_headers)
http = @http_client || Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https") if @http_client.nil?
if timeout
http.open_timeout = timeout
http.read_timeout = timeout
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
end
resp =
begin
http.request(req)
rescue StandardError => e
# Wrap any network/transport-level failure: SocketError, Errno::*,
# Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError, etc.
raise TransportError, "transport: #{e.class}: #{e.message}"
end
parse_response(resp)
end
def build_request(method, uri, json_body:, raw_body:, extra_headers:)
klass =
case method
when :get then Net::HTTP::Get
when :post then Net::HTTP::Post
when :delete then Net::HTTP::Delete
else raise ArgumentError, "unsupported HTTP method: #{method}"
end
req = klass.new(uri.request_uri)
req["Authorization"] = "Bearer #{@token}"
req["Accept"] = "application/json"
if json_body
req["Content-Type"] = "application/json"
req.body = JSON.generate(json_body)
elsif raw_body
# Caller supplies Content-Type / Content-Length via extra_headers.
req.body_stream = raw_body
end
extra_headers&.each { |k, v| req[k] = v }
req
end
def parse_response(resp)
raw = resp.body
body =
if raw.nil? || raw.empty?
nil
else
begin
JSON.parse(raw)
rescue JSON::ParserError
raw
end
end
status = resp.code.to_i
return body if status < 400
short =
case body
when Hash then body["error"] || body["detail"] || ""
when String then body[0, 200]
else ""
end
msg = "#{status} #{resp.message}: #{short}".sub(/: \z/, "")
if [401, 403].include?(status)
raise AuthError.new(msg, status: status, body: body)
end
raise APIError.new(msg, status: status, body: body)
end
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Clawdforge
# Base class for everything this SDK raises. Catch this to handle the
# whole family in one rescue.
class Error < StandardError; end
# Raised when the HTTP request never produced a response: connection
# refused, DNS failure, TCP timeout, TLS handshake failure, etc. The
# underlying exception is preserved on `cause`.
class TransportError < Error; end
# Raised on any 4xx or 5xx response from the server.
#
# Inspect:
# * `status` — the HTTP status code
# * `body` — the parsed response body (Hash if JSON, String otherwise, or nil)
class APIError < Error
attr_reader :status, :body
def initialize(message, status:, body: nil)
super(message)
@status = status
@body = body
end
def to_s
"#{super} (status=#{@status})"
end
end
# 401 / 403 — bad token, missing token, or IP not in the allowlist.
# Subclass of APIError so a single `rescue APIError` still catches it.
class AuthError < APIError; end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
module Clawdforge
# Result of a successful `POST /run`.
#
# `result` is whatever clawdforge parsed out of `claude -p --output-format
# json`: a Hash if the model returned valid JSON, otherwise the raw String.
# `ok` is always true for a RunResult — failures raise APIError instead.
RunResult = Struct.new(:ok, :result, :duration_ms, :stop_reason, keyword_init: true) do
def self.from_response(payload)
new(
ok: payload.fetch("ok", true) ? true : false,
result: payload["result"],
duration_ms: payload.fetch("duration_ms", 0).to_i,
stop_reason: payload["stop_reason"],
)
end
end
# Result of a successful `POST /files`. Pass `file_token` to
# `Client#run(files: [...])` to attach the upload to a prompt.
FileToken = Struct.new(:file_token, :ttl_secs, :size, keyword_init: true) do
def self.from_response(payload)
new(
file_token: payload.fetch("file_token"),
ttl_secs: payload.fetch("ttl_secs").to_i,
size: payload.fetch("size").to_i,
)
end
end
# A row from `GET /admin/tokens` or the result of `POST /admin/tokens`.
# `token` is the plaintext bearer string, returned ONLY at create time.
# On list responses it is nil; the server stores only a sha256 hash.
AppToken = Struct.new(
:name, :token, :ip_cidrs, :created_at, :last_used, :enabled,
keyword_init: true,
) do
def self.from_create_response(payload)
new(
name: payload.fetch("name"),
token: payload["token"],
ip_cidrs: Array(payload["ip_cidrs"]),
created_at: nil,
last_used: nil,
enabled: true,
)
end
def self.from_list_row(row)
raw = row["ip_cidrs"]
cidrs =
case raw
when String then raw.split(",").reject(&:empty?)
when Array then raw.dup
else []
end
new(
name: row.fetch("name"),
token: nil,
ip_cidrs: cidrs,
created_at: row["created_at"],
last_used: row["last_used"],
enabled: row.fetch("enabled", 1).to_i != 0,
)
end
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Clawdforge
VERSION = "0.1.0"
end

View file

@ -0,0 +1,321 @@
# frozen_string_literal: true
require "minitest/autorun"
require "webmock/minitest"
require "json"
require "tempfile"
require "stringio"
require "clawdforge"
BASE_URL = "http://forge.test:8800"
TOKEN = "cf_test_token_xxxxxxxx"
def build_client(**overrides)
Clawdforge::Client.new(
base_url: BASE_URL,
token: TOKEN,
default_timeout: 60,
**overrides,
)
end
class HealthzTest < Minitest::Test
def test_healthz_returns_parsed_json
stub_request(:get, "#{BASE_URL}/healthz")
.with(headers: { "Authorization" => "Bearer #{TOKEN}" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true, "claude_present" => true, "claude_version" => "1.2.3",
),
)
out = build_client.healthz
assert_equal true, out["ok"]
assert_equal "1.2.3", out["claude_version"]
end
end
class RunTest < Minitest::Test
def test_run_success_with_json_result
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true,
"result" => { "hello" => "world" },
"duration_ms" => 1234,
"stop_reason" => "end_turn",
),
)
r = build_client.run(prompt: 'Reply with JSON: {"hello": "world"}')
assert_kind_of Clawdforge::RunResult, r
assert_equal true, r.ok
assert_equal({ "hello" => "world" }, r.result)
assert_equal 1234, r.duration_ms
assert_equal "end_turn", r.stop_reason
end
def test_run_success_with_string_result
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => true, "result" => "plain text reply",
"duration_ms" => 800, "stop_reason" => "end_turn",
),
)
r = build_client.run(prompt: "hello")
assert_equal "plain text reply", r.result
end
def test_run_sends_full_body_and_auth
captured = {}
stub_request(:post, "#{BASE_URL}/run").with do |req|
captured[:auth] = req.headers["Authorization"]
captured[:body] = JSON.parse(req.body)
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true, "result" => {}, "duration_ms" => 1, "stop_reason" => "end_turn"),
)
build_client.run(
prompt: "hi", model: "opus", system: "be terse",
files: ["ff_abc"], timeout_secs: 42,
)
assert_equal "Bearer #{TOKEN}", captured[:auth]
assert_equal(
{
"prompt" => "hi", "model" => "opus", "system" => "be terse",
"files" => ["ff_abc"], "timeout_secs" => 42,
},
captured[:body],
)
end
def test_run_uses_default_model_and_omits_optional_fields
captured = {}
stub_request(:post, "#{BASE_URL}/run").with do |req|
captured[:body] = JSON.parse(req.body)
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true, "result" => "x", "duration_ms" => 1, "stop_reason" => "end_turn"),
)
build_client.run(prompt: "hi")
assert_equal "sonnet", captured[:body]["model"]
refute captured[:body].key?("system")
refute captured[:body].key?("files")
refute captured[:body].key?("timeout_secs")
end
def test_run_502_raises_api_error_with_body
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 502,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"ok" => false, "error" => "subprocess timed out",
"stderr" => "...", "duration_ms" => 60_000, "stop_reason" => "timeout",
),
)
err = assert_raises(Clawdforge::APIError) do
build_client.run(prompt: "hi", timeout_secs: 60)
end
assert_equal 502, err.status
assert_kind_of Hash, err.body
assert_equal "timeout", err.body["stop_reason"]
assert_match(/subprocess timed out/, err.message)
end
def test_run_401_raises_auth_error
stub_request(:post, "#{BASE_URL}/run").to_return(
status: 401,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "missing bearer"),
)
err = assert_raises(Clawdforge::AuthError) { build_client.run(prompt: "hi") }
assert_equal 401, err.status
assert_kind_of Clawdforge::APIError, err
assert_kind_of Clawdforge::Error, err
end
def test_run_transport_error_when_unreachable
stub_request(:post, "#{BASE_URL}/run").to_raise(SocketError.new("getaddrinfo: nope"))
assert_raises(Clawdforge::TransportError) { build_client.run(prompt: "hi") }
end
def test_run_empty_prompt_rejected_locally
assert_raises(ArgumentError) { build_client.run(prompt: "") }
end
end
class FilesTest < Minitest::Test
def test_upload_file_from_path
stub_request(:post, "#{BASE_URL}/files")
.with(headers: { "Content-Type" => %r{\Amultipart/form-data; boundary=} })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("file_token" => "ff_abc123", "ttl_secs" => 3600, "size" => 11),
)
Tempfile.create(["upload", ".txt"]) do |tf|
tf.binmode
tf.write("hello world")
tf.flush
ft = build_client.upload_file(tf.path, ttl_secs: 3600)
assert_kind_of Clawdforge::FileToken, ft
assert_equal "ff_abc123", ft.file_token
assert_equal 3600, ft.ttl_secs
assert_equal 11, ft.size
end
end
def test_upload_file_sends_multipart_with_ttl_and_file_parts
captured = {}
stub_request(:post, "#{BASE_URL}/files").with do |req|
captured[:content_type] = req.headers["Content-Type"]
captured[:body] = req.body
true
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("file_token" => "ff_xyz", "ttl_secs" => 60, "size" => 5),
)
Tempfile.create(["snippet", ".bin"]) do |tf|
tf.binmode
tf.write("hello")
tf.flush
build_client.upload_file(tf.path, ttl_secs: 60, filename: "snippet.txt")
end
assert_match(%r{\Amultipart/form-data; boundary=}, captured[:content_type])
assert_match(/name="ttl_secs"/, captured[:body])
assert_match(/name="file"; filename="snippet\.txt"/, captured[:body])
assert_match(/hello/, captured[:body])
end
def test_upload_file_400_raises_api_error
stub_request(:post, "#{BASE_URL}/files").to_return(
status: 400,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "ttl_secs out of range (60..86400)"),
)
Tempfile.create(["bad", ".bin"]) do |tf|
tf.binmode
tf.write("x")
tf.flush
err = assert_raises(Clawdforge::APIError) do
build_client.upload_file(tf.path, ttl_secs: 10)
end
assert_equal 400, err.status
end
end
end
class AdminTokensTest < Minitest::Test
def test_create_token
stub_request(:post, "#{BASE_URL}/admin/tokens").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"name" => "cauldron",
"token" => "cf_brandnew_xxx",
"ip_cidrs" => ["172.24.0.0/16"],
),
)
t = build_client.create_token("cauldron", ip_cidrs: ["172.24.0.0/16"])
assert_kind_of Clawdforge::AppToken, t
assert_equal "cauldron", t.name
assert_equal "cf_brandnew_xxx", t.token
assert_equal ["172.24.0.0/16"], t.ip_cidrs
end
def test_list_tokens_normalizes_csv_ip_cidrs
stub_request(:get, "#{BASE_URL}/admin/tokens").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
"tokens" => [
{
"name" => "cauldron", "ip_cidrs" => "172.24.0.0/16",
"created_at" => 100, "last_used" => 200, "enabled" => 1,
},
{
"name" => "petalparse", "ip_cidrs" => "",
"created_at" => 50, "last_used" => nil, "enabled" => 0,
},
],
),
)
toks = build_client.list_tokens
assert_equal 2, toks.length
assert_equal "cauldron", toks[0].name
assert_equal ["172.24.0.0/16"], toks[0].ip_cidrs
assert_equal true, toks[0].enabled
assert_nil toks[0].token
assert_equal [], toks[1].ip_cidrs
assert_equal false, toks[1].enabled
end
def test_revoke_token_ok
stub_request(:delete, "#{BASE_URL}/admin/tokens/cauldron").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("ok" => true),
)
assert_equal true, build_client.revoke_token("cauldron")
end
def test_revoke_token_404
stub_request(:delete, "#{BASE_URL}/admin/tokens/nosuch").to_return(
status: 404,
headers: { "Content-Type" => "application/json" },
body: JSON.generate("detail" => "no such token"),
)
err = assert_raises(Clawdforge::APIError) { build_client.revoke_token("nosuch") }
assert_equal 404, err.status
end
end
class ClientConstructionTest < Minitest::Test
def test_requires_base_url
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "", token: "cf_x") }
end
def test_requires_token
assert_raises(ArgumentError) { Clawdforge::Client.new(base_url: "http://x", token: "") }
end
def test_strips_trailing_slash
c = Clawdforge::Client.new(base_url: "http://x:8800/", token: "cf_x")
assert_equal "http://x:8800", c.base_url
end
end