clawdforge/clients/cpp/README.md
Kayos 19fe299b3d clients/cpp: apply audit findings — protocol-error guard + libcurl redirect clamp (bae34a7 → next)
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
2026-04-28 23:41:41 -07:00

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 result field of a POST /run reply 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 via nlohmann::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 = true for self-signed LAN-internal certs. Bearer tokens are passed via Authorization: Bearer …; libcurl strips this on cross-host redirects automatically (≥ 7.64.0), and the SDK additionally pins MAXREDIRS=5 and REDIR_PROTOCOLS=http,https.

  • Bearer tokens never appear in exception messages, log lines, or truncate_for_log output 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.