clawdforge/clients/cpp/include/clawdforge/client.hpp
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

112 lines
3.8 KiB
C++

// SPDX-License-Identifier: MIT
//
// Public client surface for the clawdforge HTTP API.
//
// Construct a `Client` once, hold onto it, call methods. Throws on failure —
// see `error.hpp` for the exception hierarchy.
#pragma once
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
#include <clawdforge/error.hpp>
#include <clawdforge/types.hpp>
namespace clawdforge {
/// Knobs for `Client` construction. Use designated initializers to set only
/// what you care about.
struct ClientOptions {
/// Required. e.g. `"http://localhost:8800"` or `"http://192.168.0.5:8800"`.
/// A trailing slash is fine — it gets normalized away.
std::string base_url;
/// Bearer token used for `/run`, `/files`, `/healthz`. Optional — leave
/// empty if the client is admin-only.
std::string token;
/// Bearer token for `/admin/*` endpoints. Optional — leave empty when not
/// doing token CRUD.
std::string admin_token;
/// Per-request timeout. Default 120 s leaves headroom over the server's
/// default 60 s `claude` subprocess budget.
std::chrono::seconds timeout{120};
/// Connection establishment timeout. Default 10 s.
std::chrono::seconds connect_timeout{10};
/// Override the User-Agent header.
std::string user_agent;
/// Skip TLS verification. Off by default; only useful for self-signed
/// LAN-internal certs.
bool insecure_tls{false};
};
/// HTTP client for clawdforge.
///
/// Move-only: each `Client` owns a libcurl easy handle, which doesn't share
/// well between threads. Construct one per worker thread, or guard external
/// access with a mutex.
///
/// Moved-from state: a `Client` that has been moved from holds no resources
/// and **must not** have any of its non-special-member methods invoked. Doing
/// so (including `base_url()`) is undefined behaviour. Re-assign or destroy
/// the moved-from object before further use.
class Client {
public:
explicit Client(ClientOptions opts);
~Client();
// Move-only.
Client(const Client&) = delete;
Client& operator=(const Client&) = delete;
Client(Client&&) noexcept;
Client& operator=(Client&&) noexcept;
/// Base URL the client was configured with (trailing slash trimmed).
///
/// Returns a reference into the `Client`'s internal state; the reference
/// is valid until the `Client` is destroyed or moved from. Do not call
/// on a moved-from `Client` (UB — see class doc).
[[nodiscard]] const std::string& base_url() const noexcept;
// -- public API --------------------------------------------------------
/// `GET /healthz`. Server still enforces the global IP allowlist.
[[nodiscard]] HealthzResponse healthz();
/// `POST /run`. Throws `APIError` on 502 — inspect `body()` for the
/// `RunFailure` JSON.
[[nodiscard]] RunResult run(const RunRequest& req);
/// `POST /files`. Streams the file via `curl_mime_filedata` — the SDK
/// does not slurp it into memory.
///
/// `ttl_secs == 0` lets the server pick its default (3600). Otherwise
/// must be in 60..86400.
[[nodiscard]] FileToken upload_file(std::string_view path,
std::int32_t ttl_secs = 0);
/// `POST /admin/tokens`. Requires `admin_token` on the client.
[[nodiscard]] AppToken create_token(const TokenCreateRequest& req);
/// `GET /admin/tokens`. Requires `admin_token` on the client.
[[nodiscard]] std::vector<AppTokenInfo> list_tokens();
/// `DELETE /admin/tokens/{name}`. Requires `admin_token` on the client.
/// Throws `APIError` with `status_code() == 404` if the token is unknown.
void revoke_token(std::string_view name);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace clawdforge