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

208 lines
6.8 KiB
Markdown

# clawdforge — C++ SDK
Modern C++ client for the [clawdforge](https://github.com/Sulkta-Coop/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)
```cmake
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
```bash
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:
```cmake
find_package(clawdforge CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE clawdforge::clawdforge)
```
## Quickstart
```cpp
#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
```text
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:
```cpp
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
```cpp
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
```bash
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.