HIGH: - H1: nlohmann::json::exception wrapped as ProtocolError at 5 sites in client.cpp via with_protocol_guard helper. Preserves the documented clawdforge::Error catch-all base contract; nlohmann types never leak into the message (e.what() only). - H2: libcurl MAXREDIRS=5, REDIR_PROTOCOLS_STR="http,https" (CURLOPT_REDIR_PROTOCOLS bitmask fallback for libcurl < 7.85.0), UNRESTRICTED_AUTH=0L. Defense-in-depth on top of libcurl's automatic bearer strip on cross-host redirects (>=7.64.0). MEDIUM: - M1: upload_file resolves the path via std::filesystem::canonical up front. Closes broken-symlink, symlink-loop, and TOCTOU-on-target classes without a doc burden on callers. - M2: README "Linking" section documents the public-ABI nlohmann_json implication. v0.2 wrapper deferred. - M3: README "Threat model" section documents the parse-depth concern on the result field of /run replies. Runtime guard skipped for v0.1 per audit recommendation (low yield, complexity). LOW: - L1: cxx_std_20 → cxx_std_17 in CMakeLists.txt (no C++20-only features in the library source; broader downstream reach). Examples and tests still build via designated initializers (g++ accepts these in C++17 mode). - L2: RunResult struct doc clarifies that missing ok/duration_ms decode to defaults — opt-out forward-compat. - L3: Client class doc clarifies that moved-from instances must not have any non-special-member methods invoked (UB), with explicit callout on base_url() returning an internal reference. Test-only: - cpp-httplib 0.15.3 → 0.20.1. Optional backends (OpenSSL / zlib / brotli / zstd) forced off to keep the dep graph minimal. Test-only, never on the consumer wire path. README "Test deps" section added for transparency. Tests added (12 → 23 cases, 70 → 106 assertions): - protocol_error on malformed response for healthz, run, upload_file, create_token, list_tokens (H1 regression) - redirect_clamp_test (H2 regression — TransportError after 5+ hops) - redirect_protocol_clamp (H2 regression — ftp:// Location rejected) - upload_file_canonicalize: symlink→file works, broken symlink rejected, symlink loop rejected, directory rejected (M1 regression) Verified: - cmake --build build clean (-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion -Wold-style-cast -Werror) - ctest --output-on-failure all green (Release) - ASan + UBSan: 23/23 cases, 106/106 assertions, zero diagnostics Audit: memory/clawdforge-audits/cpp-bae34a7.md
6.8 KiB
clawdforge — C++ SDK
Modern C++ client for the clawdforge
HTTP API. Wraps claude -p subprocess calls behind a bearer-token-gated REST
service.
- C++17 minimum. C++20 unlocks designated initializers in caller code (used in the examples / tests for ergonomics) but the library itself compiles cleanly at C++17.
- libcurl + nlohmann/json. No Boost.
- RAII, move-only
Client(one libcurl handle per instance). - Throwing API; full exception hierarchy under
clawdforge::Error.
Install
Option A — FetchContent (drop-in for existing CMake projects)
include(FetchContent)
FetchContent_Declare(clawdforge
GIT_REPOSITORY https://github.com/Sulkta-Coop/clawdforge.git
GIT_TAG main
SOURCE_SUBDIR clients/cpp
)
FetchContent_MakeAvailable(clawdforge)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)
CURL must be available via find_package(CURL REQUIRED). On Debian/Ubuntu:
sudo apt install libcurl4-openssl-dev.
Option B — install + find_package
git clone https://github.com/Sulkta-Coop/clawdforge.git
cd clawdforge/clients/cpp
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
sudo cmake --install build
Then in your project:
find_package(clawdforge CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)
Quickstart
#include <clawdforge/client.hpp>
#include <iostream>
namespace cf = clawdforge;
int main() {
cf::Client client{cf::ClientOptions{
.base_url = "http://localhost:8800",
.token = "cf_...",
}};
auto h = client.healthz();
std::cout << "claude_version=" << h.claude_version << "\n";
auto r = client.run(cf::RunRequest{
.prompt = R"(Reply with JSON: {"hello":"world"})",
.model = "sonnet",
.timeout_secs = 60,
});
if (auto* j = std::get_if<nlohmann::json>(&r.result)) {
std::cout << j->at("hello").get<std::string>() << "\n";
} else if (auto* s = std::get_if<std::string>(&r.result)) {
std::cout << *s << "\n";
}
}
API
clawdforge::Client (in <clawdforge/client.hpp>):
| Method | HTTP | Returns |
|---|---|---|
healthz() |
GET /healthz |
HealthzResponse |
run(req) |
POST /run |
RunResult |
upload_file(path, ttl_secs=0) |
POST /files |
FileToken |
create_token(req) |
POST /admin/tokens |
AppToken |
list_tokens() |
GET /admin/tokens |
std::vector<AppTokenInfo> |
revoke_token(name) |
DELETE /admin/tokens/<name> |
void |
All methods throw on failure. ClientOptions lets you set base_url, token,
admin_token, timeout, connect_timeout, user_agent, and insecure_tls.
Errors
clawdforge::Error // abstract base, derives from std::runtime_error
├── clawdforge::AuthError // 401 / 403, or missing token on the client
├── clawdforge::APIError // any other non-2xx — has status_code() + body()
├── clawdforge::TransportError // libcurl-level failures
└── clawdforge::ProtocolError // bad URL, missing fields, malformed JSON
Catch broadly:
try {
client.run(req);
} catch (const cf::AuthError& e) { /* refresh / abort */ }
catch (const cf::APIError& e) { std::cerr << e.status_code() << " " << e.body(); }
catch (const cf::TransportError& e) { /* retry */ }
catch (const cf::Error& e) { /* anything else */ }
POST /run returns HTTP 502 on subprocess failure. Surfaced as APIError
with status_code() == 502; body() parses as the documented RunFailure
shape.
File uploads
auto ft = client.upload_file("/path/to/recipe.png", /*ttl_secs=*/3600);
auto res = client.run(cf::RunRequest{
.prompt = "extract recipe data",
.files = {ft.file_token},
});
The file is streamed off disk via libcurl's curl_mime_filedata — no
in-memory buffering of the payload.
Threading
Client is move-only and not thread-safe. Construct one per worker thread
or wrap external accesses in a mutex. Multiple Client instances share the
process-wide libcurl global state safely (refcounted internally).
Linking
nlohmann_json is a public dependency of the SDK (used in types.hpp for
on-wire structs). Consumers therefore compile against and link nlohmann_json
even when only calling, say, healthz(). If you already vendor a different
version of nlohmann_json, you'll want to pin a compatible version (3.10+) to
avoid ODR drift across the boundary.
This is a known v0.1 limitation. A v0.2 refactor will hide nlohmann behind a
thin clawdforge::JsonValue wrapper to drop it from the public ABI.
libcurl is private and not exposed across the public header.
Threat model
The SDK is intended for clawdforge servers that the caller trusts (LAN-deployed, bearer-gated). With that in mind:
-
The
resultfield of aPOST /runreply is untrusted user-influenced data — it carries Claude's response, which can be shaped by whatever ended up in the prompt. The SDK parses it as JSON vianlohmann::json::parse, which is recursion-based. A pathologically deep object (thousands of nested arrays) could push the stack arbitrarily far before the parser bails. The SDK does not currently apply a max-depth guard. If your threat model includes attacker-controlled prompts, either cap prompt size upstream or pre-validate the response body before letting the SDK parse it. -
TLS verification is on by default. Only disable via
ClientOptions::insecure_tls = truefor self-signed LAN-internal certs. Bearer tokens are passed viaAuthorization: Bearer …; libcurl strips this on cross-host redirects automatically (≥ 7.64.0), and the SDK additionally pinsMAXREDIRS=5andREDIR_PROTOCOLS=http,https. -
Bearer tokens never appear in exception messages, log lines, or
truncate_for_logoutput by design. If you observe one, that's a bug — please file an issue.
Build options
| Option | Default | Effect |
|---|---|---|
CLAWDFORGE_BUILD_TESTS |
ON (top-level), OFF (subdirectory) |
doctest-based suite |
CLAWDFORGE_BUILD_EXAMPLES |
ON (top-level), OFF (subdirectory) |
builds clawdforge_basic_example |
CLAWDFORGE_WARNINGS_AS_ERRORS |
ON |
-Werror / /WX on the library target |
Development
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
The test suite spins up a cpp-httplib mock server in-process — no real
clawdforge needed.
Test deps
| Dep | Version | Notes |
|---|---|---|
cpp-httplib |
0.20.1 |
Test-only; mock binds to 127.0.0.1:0. Optional backends (OpenSSL / zlib / brotli / zstd) are forced off when fetched. |
doctest |
2.4.11 |
Test-only. |
Test deps are NOT linked into the shipped clawdforge::clawdforge target.
License
MIT. See LICENSE at the repo root.