diff --git a/clients/c/CMakeLists.txt b/clients/c/CMakeLists.txt index 559a09c..dd2b0e7 100644 --- a/clients/c/CMakeLists.txt +++ b/clients/c/CMakeLists.txt @@ -24,7 +24,7 @@ cmake_minimum_required(VERSION 3.16) project(clawdforge - VERSION 0.1.0 + VERSION 0.2.0 DESCRIPTION "C SDK for the clawdforge HTTP service" LANGUAGES C) @@ -44,7 +44,7 @@ include(GNUInstallDirs) find_package(CURL REQUIRED) # Common warning flags. Tests bump to -Werror. -set(CF_WARN_FLAGS -Wall -Wextra -Wpedantic) +set(CF_WARN_FLAGS -Wall -Wextra -Wpedantic -Wshadow) if(CLAWDFORGE_ASAN) list(APPEND CF_WARN_FLAGS -fsanitize=address -fno-omit-frame-pointer -g) @@ -57,6 +57,7 @@ set(CF_SOURCES src/client.c src/http.c src/json.c + src/session.c src/cjson/cJSON.c) # ---------------------------------------------------------------------------- diff --git a/clients/c/README.md b/clients/c/README.md index 8fdd39b..780c8b0 100644 --- a/clients/c/README.md +++ b/clients/c/README.md @@ -1,9 +1,14 @@ # clawdforge — C SDK A small synchronous C client for the [clawdforge](../../README.md) HTTP service. -clawdforge wraps `claude -p` subprocess calls behind a bearer-token-gated REST -API; this SDK is what you reach for from C, or what you FFI into from any -language with a C ABI. +clawdforge wraps `claude -p` and ACPX (multi-turn) behind a bearer-token-gated +REST API; this SDK is what you reach for from C, or what you FFI into from +any language with a C ABI. + +- **v0.1** surface (single-turn `cf_run`, file uploads, admin tokens) is + unchanged. Existing callers keep working byte-for-byte. +- **v0.2** adds multi-turn `cf_session_*` for context-preserving turns — + see [Multi-turn / Sessions (v0.2)](#multi-turn--sessions-v02) below. - **C11 required.** The implementation uses C11 atomics (``) for the shared libcurl-init refcount. The public header itself stays @@ -210,6 +215,129 @@ cf_status_t cf_admin_revoke_token(cf_client_t *c, All require an admin token via `cf_client_set_admin_token` first. +## Multi-turn / Sessions (v0.2) + +v0.2 adds multi-turn `Session` support backed by the server's ACPX path. +The surface is **purely additive** — every v0.1 entry point keeps working +unchanged. Use `cf_run` for one-shot prompts; reach for sessions when you +need context across turns. + +### Lifecycle + +```c +#include + +cf_client_t *c = cf_client_new("http://192.168.0.5:8800", "cf_xxx"); +cf_error_t err = {0}; + +/* 1. Create a session */ +cf_session_t *s = NULL; +cf_session_options_t sopts = { .agent = "claude" }; +if (cf_session_new(c, &sopts, &s, &err) != CF_OK) { + fprintf(stderr, "session_new: %s\n", cf_error_message(&err)); + cf_error_free(&err); + cf_client_free(c); + return 1; +} +printf("session id: %s\n", cf_session_id(s)); + +/* 2. Send a turn */ +cf_turn_options_t topts = { 0 }; +cf_turn_result_t *r = NULL; +if (cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err) != CF_OK) { + fprintf(stderr, "turn: %s\n", cf_error_message(&err)); + cf_error_free(&err); + cf_session_free(s); /* implicit close */ + cf_client_free(c); + return 1; +} +printf("text: %s\n", cf_turn_result_text(r)); /* owned by r — do not free */ +printf("turn_index: %d, duration: %ld ms\n", r->turn_index, r->duration_ms); +cf_turn_result_free(r); + +/* 3. Close (idempotent — safe to call multiple times) */ +cf_session_close(s, &err); +cf_session_free(s); + +cf_client_free(c); +``` + +### Idempotent close + +`cf_session_close` is **idempotent**: the first successful call issues +`DELETE /sessions/{id}`, and any subsequent call short-circuits and +returns `CF_OK` without hitting the wire. This means you can safely +call it from cleanup paths (e.g. wrapped in a goto-cleanup pattern) +without worrying about double-free. If you skip the explicit close, +`cf_session_free` makes a best-effort close internally before tearing +the handle down. + +### Turn events and `cf_turn_result_text` + +A `cf_turn_result_t` exposes the structured event log that came back +from the turn: + +```c +typedef struct cf_turn_event { + char *type; /* "text", "tool_call", "thinking", ... */ + char *content; /* nullable */ + char *name; /* nullable */ + char *args_json; /* nullable, raw JSON */ + char *result_json; /* nullable, raw JSON */ +} cf_turn_event_t; +``` + +If you only want the visible text reply, `cf_turn_result_text(r)` +concatenates every `type=="text"` event's content into a single string. +**The pointer it returns is owned by `r` — do not free it.** It's +computed lazily on first call and cached. + +For tool-call events, `args_json` and `result_json` are raw JSON strings +that you can hand to `cJSON_Parse` if you need structured access. + +### State + listing + +```c +/* Look up state by id */ +cf_session_state_t *st = NULL; +cf_session_state_get(c, "sess_abc", &st, &err); +printf("turn_count: %d, closed_at: %ld\n", st->turn_count, st->closed_at); +cf_session_state_free(st); + +/* List the sessions visible to your token */ +cf_session_list_t *list = NULL; +cf_session_list_get(c, &list, &err); +for (size_t i = 0; i < list->items_count; i++) { + printf("- %s (%d turns)\n", + list->items[i].session_id, list->items[i].turn_count); +} +cf_session_list_free(list); +``` + +`last_turn_at` and `closed_at` use **-1** to represent the JSON `null` +state (never had a turn / still open) — pick whichever sentinel feels +right for your call site. + +### Memory ownership summary + +| Producer | How to release | +| --- | --- | +| `cf_session_new` | `cf_session_free` | +| `cf_session_turn` | `cf_turn_result_free` | +| `cf_session_state_get` | `cf_session_state_free` | +| `cf_session_list_get` | `cf_session_list_free` | +| `cf_turn_result_text` | (do not free — owned by the result) | +| `cf_session_id` / `cf_session_agent` | (do not free — owned by the session) | + +Every freer is `NULL`-safe. + +### What's not in v0.2 + +- No streaming (turn returns the whole event batch when finished). +- No background close on session-handle drop — call + `cf_session_close` explicitly (or rely on the implicit close inside + `cf_session_free`). + ## Build options | Option | Default | What it does | diff --git a/clients/c/include/clawdforge.h b/clients/c/include/clawdforge.h index 78df726..b2fc994 100644 --- a/clients/c/include/clawdforge.h +++ b/clients/c/include/clawdforge.h @@ -57,9 +57,9 @@ extern "C" { /* ------------------------------------------------------------------ */ #define CLAWDFORGE_VERSION_MAJOR 0 -#define CLAWDFORGE_VERSION_MINOR 1 +#define CLAWDFORGE_VERSION_MINOR 2 #define CLAWDFORGE_VERSION_PATCH 0 -#define CLAWDFORGE_VERSION_STRING "0.1.0" +#define CLAWDFORGE_VERSION_STRING "0.2.0" /* Maximum HTTP response body size accepted by the SDK. A response * larger than this aborts mid-transfer with CF_ERR_TRANSPORT, which @@ -282,6 +282,139 @@ CF_API cf_status_t cf_admin_revoke_token(cf_client_t *c, const char *name, cf_error_t *err); +/* ------------------------------------------------------------------ */ +/* /sessions (v0.2 — multi-turn) */ +/* ------------------------------------------------------------------ */ + +/* + * v0.2 adds multi-turn Sessions backed by the server's ACPX path. + * The surface is purely additive — v0.1 callers (cf_run / cf_upload_file + * / cf_admin_*) keep working unchanged. + * + * Lifecycle: + * + * cf_client_t *c = cf_client_new(...); + * cf_session_t *s = NULL; + * cf_session_options_t sopts = { .agent = "claude" }; + * cf_session_new(c, &sopts, &s, &err); + * + * cf_turn_result_t *r = NULL; + * cf_turn_options_t topts = {0}; + * cf_session_turn(s, "do the thing", &topts, &r, &err); + * printf("%s\n", cf_turn_result_text(r)); // pointer owned by r + * cf_turn_result_free(r); + * + * cf_session_close(s, &err); // idempotent + * cf_session_free(s); // also closes if needed + * + * cf_client_free(c); + * + * Threading: a cf_session_t inherits the cf_client_t's threading model. + * Don't share one across threads; one client + one session per thread, + * or guard with your own mutex. + * + * Errors: every fallible call takes a cf_error_t out-param. The bearer + * token is NEVER reflected in any error message or log output. + */ + +/* Opaque. */ +typedef struct cf_session cf_session_t; + +typedef struct cf_session_options { + const char *agent; /* default "claude"; NULL = server default */ + /* Reserved for future expansion: meta_json. NULL for v0.2. */ + const char *meta_json; +} cf_session_options_t; + +typedef struct cf_turn_options { + const char *const *files; /* nullable; array of "ff_..." tokens */ + size_t files_count; + int timeout_secs; /* 0 = use client default */ +} cf_turn_options_t; + +typedef struct cf_turn_event { + char *type; /* e.g. "text", "tool_call", "thinking" */ + char *content; /* nullable */ + char *name; /* nullable */ + char *args_json; /* nullable, raw JSON */ + char *result_json; /* nullable, raw JSON */ +} cf_turn_event_t; + +typedef struct cf_turn_result { + int ok; /* 0 / 1 */ + char *session_id; + int turn_index; + cf_turn_event_t *events; + size_t events_count; + char *stop_reason; /* nullable */ + long duration_ms; + /* Internal: lazy cache for cf_turn_result_text. Don't touch. */ + char *_cached_text; +} cf_turn_result_t; + +typedef struct cf_session_state { + char *session_id; + char *agent; + char *app_name; + long created_at; + long last_turn_at; /* -1 if null */ + int turn_count; + long closed_at; /* -1 if null */ +} cf_session_state_t; + +typedef struct cf_session_list { + cf_session_state_t *items; + size_t items_count; +} cf_session_list_t; + +/* Construct a new session. opts may be NULL for defaults. On CF_OK, + * *out is a fresh cf_session_t* — release with cf_session_free(). */ +CF_API cf_status_t cf_session_new(cf_client_t *c, + const cf_session_options_t *opts, + cf_session_t **out, + cf_error_t *err); + +/* Run a single turn. opts may be NULL for defaults. On CF_OK, *out is + * a fresh cf_turn_result_t* — release with cf_turn_result_free(). + * Calling after cf_session_close returns CF_ERR_USAGE. */ +CF_API cf_status_t cf_session_turn(cf_session_t *s, + const char *prompt, + const cf_turn_options_t *opts, + cf_turn_result_t **out, + cf_error_t *err); + +/* Idempotent close. The first successful call issues DELETE /sessions/{id}; + * subsequent calls short-circuit and return CF_OK with no HTTP traffic. */ +CF_API cf_status_t cf_session_close(cf_session_t *s, cf_error_t *err); + +/* Read-only accessors into the session handle. Strings live as long as + * the session; do not free. */ +CF_API const char *cf_session_id(const cf_session_t *s); +CF_API const char *cf_session_agent(const cf_session_t *s); + +/* Releases. Implicitly closes if the session is still open and the + * caller never called cf_session_close. Safe to pass NULL. */ +CF_API void cf_session_free(cf_session_t *s); + +/* Server lookups (per-token). */ +CF_API cf_status_t cf_session_state_get(cf_client_t *c, + const char *session_id, + cf_session_state_t **out, + cf_error_t *err); + +CF_API cf_status_t cf_session_list_get(cf_client_t *c, + cf_session_list_t **out, + cf_error_t *err); + +/* Concatenate every event with type=="text" into a single string. + * Returns a pointer OWNED by the result — caller must NOT free. + * Computed lazily on first call. Returns "" if no text events. */ +CF_API const char *cf_turn_result_text(cf_turn_result_t *r); + +CF_API void cf_turn_result_free(cf_turn_result_t *r); +CF_API void cf_session_state_free(cf_session_state_t *s); +CF_API void cf_session_list_free(cf_session_list_t *l); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/clients/c/src/internal.h b/clients/c/src/internal.h index 577105d..c99484b 100644 --- a/clients/c/src/internal.h +++ b/clients/c/src/internal.h @@ -20,6 +20,24 @@ struct cf_client { long timeout_secs; }; +/* The opaque public session. v0.2. + * + * Field ownership: + * client — borrowed pointer, NOT owned. The caller is responsible + * for outliving the session with the client. + * id, agent — heap-owned, freed by cf_session_free. + * closed — 0 / 1; flipped to 1 after a successful DELETE so + * cf_session_close is idempotent and cf_session_turn + * short-circuits to CF_ERR_USAGE. + */ +struct cf_session { + cf_client_t *client; + char *id; + char *agent; + long created_at; + int closed; +}; + /* Growable byte buffer used for response bodies. */ typedef struct cf_buf { char *data; diff --git a/clients/c/src/session.c b/clients/c/src/session.c new file mode 100644 index 0000000..ab626df --- /dev/null +++ b/clients/c/src/session.c @@ -0,0 +1,667 @@ +/* v0.2: multi-turn /sessions endpoints. + * + * The session API mirrors the v0.1 hygiene contract: + * + * - every malloc on a success path is paired with the matching free() + * in cf_session_free / cf_turn_result_free / cf_session_state_free / + * cf_session_list_free. + * - every malloc on an error path is unwound before returning. + * - the bearer token is NEVER reflected into a cf_error_t message, + * not even by accident: error strings are built from server-provided + * `detail` / `error` fields and HTTP status codes only. + * + * Session ID validation is mirrored from cf_admin_revoke_token: the + * server-side allow-list is [A-Za-z0-9_-]+, so we reject anything else + * client-side before interpolating into the URL. This stops path + * traversal ("a/../healthz" collapsing through a reverse proxy) and + * keeps the failure earlier, with a clearer error message, than the + * server's 404 would. + */ + +#include "internal.h" + +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* shared helpers (small enough to duplicate vs. extern out of client.c) */ +/* ------------------------------------------------------------------ */ + +static char *cf_strdup_or_null(const char *s) { + if (!s) return NULL; + size_t n = strlen(s) + 1; + char *p = (char *)malloc(n); + if (!p) return NULL; + memcpy(p, s, n); + return p; +} + +/* Translate a non-2xx response to a populated cf_error_t. */ +static cf_status_t check_http_status(const cf_http_response_t *r, + cf_error_t *err) { + if (r->http_status >= 200 && r->http_status < 300) return CF_OK; + cf_status_t code = cf_status_from_http(r->http_status); + char *detail = cf_json_extract_error_message(r->body.data, r->body.len); + cf_err_set(err, code, r->http_status, + "server returned %ld: %s", + r->http_status, + detail ? detail : "(no body)"); + free(detail); + return code; +} + +/* Mirror of admin_token_name_is_safe — same allow-list as the server's + * for session IDs. Returns 1 iff [A-Za-z0-9_-]+ and non-empty. */ +static int session_id_is_safe(const char *id) { + if (!id || !*id) return 0; + for (const unsigned char *p = (const unsigned char *)id; *p; ++p) { + unsigned char ch = *p; + if ((ch >= 'a' && ch <= 'z') || + (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || + ch == '_' || ch == '-') continue; + return 0; + } + return 1; +} + +/* ------------------------------------------------------------------ */ +/* JSON parse helpers */ +/* ------------------------------------------------------------------ */ + +static cf_status_t parse_session_state_object(cJSON *node, + cf_session_state_t *out, + cf_error_t *err) { + cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id"); + cJSON *jagent = cJSON_GetObjectItemCaseSensitive(node, "agent"); + cJSON *japp = cJSON_GetObjectItemCaseSensitive(node, "app_name"); + cJSON *jcreate = cJSON_GetObjectItemCaseSensitive(node, "created_at"); + cJSON *jlast = cJSON_GetObjectItemCaseSensitive(node, "last_turn_at"); + cJSON *jturn = cJSON_GetObjectItemCaseSensitive(node, "turn_count"); + cJSON *jclosed = cJSON_GetObjectItemCaseSensitive(node, "closed_at"); + + out->last_turn_at = -1; + out->closed_at = -1; + + if (cJSON_IsString(jsid) && jsid->valuestring) { + out->session_id = cf_strdup_or_null(jsid->valuestring); + if (!out->session_id) goto oom; + } + if (cJSON_IsString(jagent) && jagent->valuestring) { + out->agent = cf_strdup_or_null(jagent->valuestring); + if (!out->agent) goto oom; + } + if (cJSON_IsString(japp) && japp->valuestring) { + out->app_name = cf_strdup_or_null(japp->valuestring); + if (!out->app_name) goto oom; + } + if (cJSON_IsNumber(jcreate)) out->created_at = (long)jcreate->valuedouble; + if (cJSON_IsNumber(jlast)) out->last_turn_at = (long)jlast->valuedouble; + if (cJSON_IsNumber(jturn)) out->turn_count = (int)jturn->valuedouble; + if (cJSON_IsNumber(jclosed)) out->closed_at = (long)jclosed->valuedouble; + + return CF_OK; + +oom: + free(out->session_id); out->session_id = NULL; + free(out->agent); out->agent = NULL; + free(out->app_name); out->app_name = NULL; + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); +} + +/* Take a cJSON event object and populate a cf_turn_event_t. On OOM + * frees any partially-populated fields and returns -1. */ +static int parse_turn_event(cJSON *e, cf_turn_event_t *out) { + if (!cJSON_IsObject(e)) { + /* Skip — leave the slot zeroed. The events_count was sized + * conservatively from the array length. */ + return 0; + } + cJSON *jtype = cJSON_GetObjectItemCaseSensitive(e, "type"); + cJSON *jcontent = cJSON_GetObjectItemCaseSensitive(e, "content"); + cJSON *jname = cJSON_GetObjectItemCaseSensitive(e, "name"); + cJSON *jargs = cJSON_GetObjectItemCaseSensitive(e, "args"); + cJSON *jresult = cJSON_GetObjectItemCaseSensitive(e, "result"); + + if (cJSON_IsString(jtype) && jtype->valuestring) { + out->type = cf_strdup_or_null(jtype->valuestring); + if (!out->type) goto oom; + } + if (cJSON_IsString(jcontent) && jcontent->valuestring) { + out->content = cf_strdup_or_null(jcontent->valuestring); + if (!out->content) goto oom; + } + if (cJSON_IsString(jname) && jname->valuestring) { + out->name = cf_strdup_or_null(jname->valuestring); + if (!out->name) goto oom; + } + if (jargs && !cJSON_IsNull(jargs)) { + char *txt = cJSON_PrintUnformatted(jargs); + if (!txt) goto oom; + out->args_json = cf_strdup_or_null(txt); + cJSON_free(txt); + if (!out->args_json) goto oom; + } + if (jresult && !cJSON_IsNull(jresult)) { + char *txt = cJSON_PrintUnformatted(jresult); + if (!txt) goto oom; + out->result_json = cf_strdup_or_null(txt); + cJSON_free(txt); + if (!out->result_json) goto oom; + } + return 0; + +oom: + free(out->type); out->type = NULL; + free(out->content); out->content = NULL; + free(out->name); out->name = NULL; + free(out->args_json); out->args_json = NULL; + free(out->result_json); out->result_json = NULL; + return -1; +} + +/* ------------------------------------------------------------------ */ +/* cf_session_new */ +/* ------------------------------------------------------------------ */ + +cf_status_t cf_session_new(cf_client_t *c, + const cf_session_options_t *opts, + cf_session_t **out, + cf_error_t *err) { + if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg"); + *out = NULL; + + /* Build request body { "agent": ..., "meta": ... }. */ + cJSON *body = cJSON_CreateObject(); + if (!body) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + + const char *agent = (opts && opts->agent && *opts->agent) ? opts->agent : "claude"; + if (!cJSON_AddStringToObject(body, "agent", agent)) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + if (opts && opts->meta_json && *opts->meta_json) { + cJSON *meta = cJSON_Parse(opts->meta_json); + if (!meta) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_USAGE, 0, "meta_json is not valid JSON"); + } + cJSON_AddItemToObject(body, "meta", meta); + } + + char *body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory serialising"); + + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_status_t s = cf_http_post_json(c, "/sessions", NULL, body_str, &resp, err); + cJSON_free(body_str); + if (s != CF_OK) { + cf_http_response_free(&resp); + return s; + } + s = check_http_status(&resp, err); + if (s != CF_OK) { + cf_http_response_free(&resp); + return s; + } + + cJSON *node = NULL; + s = cf_json_parse(resp.body.data, resp.body.len, &node, err); + cf_http_response_free(&resp); + if (s != CF_OK) return s; + + cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id"); + cJSON *jagent = cJSON_GetObjectItemCaseSensitive(node, "agent"); + cJSON *jcreate = cJSON_GetObjectItemCaseSensitive(node, "created_at"); + + if (!cJSON_IsString(jsid) || !jsid->valuestring) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_PARSE, 0, "missing session_id in response"); + } + + cf_session_t *sess = (cf_session_t *)calloc(1, sizeof *sess); + if (!sess) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + sess->client = c; + sess->id = cf_strdup_or_null(jsid->valuestring); + if (!sess->id) { + free(sess); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + if (cJSON_IsString(jagent) && jagent->valuestring) { + sess->agent = cf_strdup_or_null(jagent->valuestring); + if (!sess->agent) { + free(sess->id); + free(sess); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } else { + sess->agent = cf_strdup_or_null(agent); + if (!sess->agent) { + free(sess->id); + free(sess); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } + sess->created_at = cJSON_IsNumber(jcreate) ? (long)jcreate->valuedouble : 0; + sess->closed = 0; + + cJSON_Delete(node); + *out = sess; + return CF_OK; +} + +/* ------------------------------------------------------------------ */ +/* cf_session_turn */ +/* ------------------------------------------------------------------ */ + +cf_status_t cf_session_turn(cf_session_t *s, + const char *prompt, + const cf_turn_options_t *opts, + cf_turn_result_t **out, + cf_error_t *err) { + if (!s || !s->client || !prompt || !out) { + return cf_err_set(err, CF_ERR_USAGE, 0, "null arg"); + } + if (s->closed) { + return cf_err_set(err, CF_ERR_USAGE, 0, "session is closed"); + } + if (!*prompt) { + return cf_err_set(err, CF_ERR_USAGE, 0, "prompt is required"); + } + if (!session_id_is_safe(s->id)) { + return cf_err_set(err, CF_ERR_USAGE, 0, + "invalid session id; must match [A-Za-z0-9_-]+"); + } + *out = NULL; + + /* Build path. session id alphabet validated above. */ + char path[320]; + int n = snprintf(path, sizeof path, "/sessions/%s/turn", s->id); + if (n <= 0 || (size_t)n >= sizeof path) { + return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long"); + } + + /* Build body. */ + cJSON *body = cJSON_CreateObject(); + if (!body) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + if (!cJSON_AddStringToObject(body, "prompt", prompt)) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + if (opts && opts->files && opts->files_count > 0) { + cJSON *arr = cJSON_AddArrayToObject(body, "files"); + if (!arr) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + for (size_t i = 0; i < opts->files_count; ++i) { + const char *f = opts->files[i]; + if (!f) continue; + cJSON *str = cJSON_CreateString(f); + if (!str) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + cJSON_AddItemToArray(arr, str); + } + } + if (opts && opts->timeout_secs > 0) { + if (!cJSON_AddNumberToObject(body, "timeout_secs", opts->timeout_secs)) { + cJSON_Delete(body); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } + char *body_str = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!body_str) return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_status_t st = cf_http_post_json(s->client, path, NULL, body_str, &resp, err); + cJSON_free(body_str); + if (st != CF_OK) { + cf_http_response_free(&resp); + return st; + } + st = check_http_status(&resp, err); + if (st != CF_OK) { + cf_http_response_free(&resp); + return st; + } + + cJSON *node = NULL; + st = cf_json_parse(resp.body.data, resp.body.len, &node, err); + cf_http_response_free(&resp); + if (st != CF_OK) return st; + + cJSON *jok = cJSON_GetObjectItemCaseSensitive(node, "ok"); + cJSON *jsid = cJSON_GetObjectItemCaseSensitive(node, "session_id"); + cJSON *jturn = cJSON_GetObjectItemCaseSensitive(node, "turn_index"); + cJSON *jevs = cJSON_GetObjectItemCaseSensitive(node, "events"); + cJSON *jstop = cJSON_GetObjectItemCaseSensitive(node, "stop_reason"); + cJSON *jdur = cJSON_GetObjectItemCaseSensitive(node, "duration_ms"); + + cf_turn_result_t *r = (cf_turn_result_t *)calloc(1, sizeof *r); + if (!r) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + r->ok = cJSON_IsTrue(jok) ? 1 : 0; + if (cJSON_IsString(jsid) && jsid->valuestring) { + r->session_id = cf_strdup_or_null(jsid->valuestring); + if (!r->session_id) { + cf_turn_result_free(r); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } + if (cJSON_IsNumber(jturn)) r->turn_index = (int)jturn->valuedouble; + if (cJSON_IsString(jstop) && jstop->valuestring) { + r->stop_reason = cf_strdup_or_null(jstop->valuestring); + if (!r->stop_reason) { + cf_turn_result_free(r); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } + if (cJSON_IsNumber(jdur)) r->duration_ms = (long)jdur->valuedouble; + + if (cJSON_IsArray(jevs)) { + int ec = cJSON_GetArraySize(jevs); + if (ec > 0) { + r->events = (cf_turn_event_t *)calloc((size_t)ec, sizeof *r->events); + if (!r->events) { + cf_turn_result_free(r); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + for (int i = 0; i < ec; ++i) { + cJSON *e = cJSON_GetArrayItem(jevs, i); + if (parse_turn_event(e, &r->events[i]) != 0) { + /* OOM mid-event. Set count to what we successfully built + * so the freer doesn't walk uninitialised slots. */ + r->events_count = (size_t)i; + cf_turn_result_free(r); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + } + r->events_count = (size_t)ec; + } + } + + cJSON_Delete(node); + *out = r; + return CF_OK; +} + +/* ------------------------------------------------------------------ */ +/* cf_session_close (idempotent) */ +/* ------------------------------------------------------------------ */ + +cf_status_t cf_session_close(cf_session_t *s, cf_error_t *err) { + if (!s || !s->client) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg"); + if (s->closed) return CF_OK; + + if (!session_id_is_safe(s->id)) { + return cf_err_set(err, CF_ERR_USAGE, 0, + "invalid session id; must match [A-Za-z0-9_-]+"); + } + + char path[320]; + int n = snprintf(path, sizeof path, "/sessions/%s", s->id); + if (n <= 0 || (size_t)n >= sizeof path) { + return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long"); + } + + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_status_t st = cf_http_delete(s->client, path, NULL, &resp, err); + if (st != CF_OK) { + cf_http_response_free(&resp); + return st; + } + st = check_http_status(&resp, err); + cf_http_response_free(&resp); + if (st != CF_OK) return st; + + s->closed = 1; + return CF_OK; +} + +/* ------------------------------------------------------------------ */ +/* accessors */ +/* ------------------------------------------------------------------ */ + +const char *cf_session_id(const cf_session_t *s) { + if (!s) return NULL; + return s->id; +} + +const char *cf_session_agent(const cf_session_t *s) { + if (!s) return NULL; + return s->agent; +} + +/* ------------------------------------------------------------------ */ +/* cf_session_free */ +/* ------------------------------------------------------------------ */ + +void cf_session_free(cf_session_t *s) { + if (!s) return; + /* Best-effort implicit close. Errors are intentionally swallowed — + * cf_session_free has a void return and should never partially leak, + * and the server-side close is idempotent. */ + if (!s->closed && s->client && session_id_is_safe(s->id)) { + char path[320]; + int n = snprintf(path, sizeof path, "/sessions/%s", s->id); + if (n > 0 && (size_t)n < sizeof path) { + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_error_t scratch = {0}; + (void)cf_http_delete(s->client, path, NULL, &resp, &scratch); + cf_http_response_free(&resp); + cf_error_free(&scratch); + } + } + free(s->id); + free(s->agent); + free(s); +} + +/* ------------------------------------------------------------------ */ +/* cf_session_state_get / cf_session_list_get */ +/* ------------------------------------------------------------------ */ + +cf_status_t cf_session_state_get(cf_client_t *c, + const char *session_id, + cf_session_state_t **out, + cf_error_t *err) { + if (!c || !session_id || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg"); + *out = NULL; + if (!session_id_is_safe(session_id)) { + return cf_err_set(err, CF_ERR_USAGE, 0, + "invalid session id; must match [A-Za-z0-9_-]+"); + } + char path[320]; + int n = snprintf(path, sizeof path, "/sessions/%s", session_id); + if (n <= 0 || (size_t)n >= sizeof path) { + return cf_err_set(err, CF_ERR_USAGE, 0, "session id too long"); + } + + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_status_t s = cf_http_get(c, path, NULL, &resp, err); + if (s != CF_OK) { cf_http_response_free(&resp); return s; } + s = check_http_status(&resp, err); + if (s != CF_OK) { cf_http_response_free(&resp); return s; } + + cJSON *node = NULL; + s = cf_json_parse(resp.body.data, resp.body.len, &node, err); + cf_http_response_free(&resp); + if (s != CF_OK) return s; + + cf_session_state_t *st = (cf_session_state_t *)calloc(1, sizeof *st); + if (!st) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + s = parse_session_state_object(node, st, err); + cJSON_Delete(node); + if (s != CF_OK) { + cf_session_state_free(st); + return s; + } + *out = st; + return CF_OK; +} + +cf_status_t cf_session_list_get(cf_client_t *c, + cf_session_list_t **out, + cf_error_t *err) { + if (!c || !out) return cf_err_set(err, CF_ERR_USAGE, 0, "null arg"); + *out = NULL; + + cf_http_response_t resp; + cf_http_response_init(&resp); + cf_status_t s = cf_http_get(c, "/sessions", NULL, &resp, err); + if (s != CF_OK) { cf_http_response_free(&resp); return s; } + s = check_http_status(&resp, err); + if (s != CF_OK) { cf_http_response_free(&resp); return s; } + + cJSON *node = NULL; + s = cf_json_parse(resp.body.data, resp.body.len, &node, err); + cf_http_response_free(&resp); + if (s != CF_OK) return s; + + cJSON *items = cJSON_GetObjectItemCaseSensitive(node, "sessions"); + if (!cJSON_IsArray(items)) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_PARSE, 0, "expected 'sessions' array"); + } + + cf_session_list_t *list = (cf_session_list_t *)calloc(1, sizeof *list); + if (!list) { + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + + int n = cJSON_GetArraySize(items); + if (n > 0) { + list->items = (cf_session_state_t *)calloc((size_t)n, sizeof *list->items); + if (!list->items) { + free(list); + cJSON_Delete(node); + return cf_err_set(err, CF_ERR_OOM, 0, "out of memory"); + } + for (int i = 0; i < n; ++i) { + cJSON *e = cJSON_GetArrayItem(items, i); + if (!cJSON_IsObject(e)) continue; + cf_status_t ps = parse_session_state_object(e, &list->items[list->items_count], err); + if (ps != CF_OK) { + cJSON_Delete(node); + cf_session_list_free(list); + return ps; + } + list->items_count++; + } + } + + cJSON_Delete(node); + *out = list; + return CF_OK; +} + +/* ------------------------------------------------------------------ */ +/* cf_turn_result_text — lazy concat */ +/* ------------------------------------------------------------------ */ + +const char *cf_turn_result_text(cf_turn_result_t *r) { + if (!r) return ""; + if (r->_cached_text) return r->_cached_text; + + /* Compute total length first to avoid quadratic realloc. */ + size_t total = 0; + for (size_t i = 0; i < r->events_count; ++i) { + const cf_turn_event_t *e = &r->events[i]; + if (e->type && strcmp(e->type, "text") == 0 && e->content) { + size_t add = strlen(e->content); + if (add > SIZE_MAX - total - 1) { + /* Overflow — return empty rather than truncating. */ + return ""; + } + total += add; + } + } + char *buf = (char *)malloc(total + 1); + if (!buf) return ""; + size_t off = 0; + for (size_t i = 0; i < r->events_count; ++i) { + const cf_turn_event_t *e = &r->events[i]; + if (e->type && strcmp(e->type, "text") == 0 && e->content) { + size_t add = strlen(e->content); + memcpy(buf + off, e->content, add); + off += add; + } + } + buf[off] = '\0'; + r->_cached_text = buf; + return r->_cached_text; +} + +/* ------------------------------------------------------------------ */ +/* freers */ +/* ------------------------------------------------------------------ */ + +void cf_turn_result_free(cf_turn_result_t *r) { + if (!r) return; + free(r->session_id); + free(r->stop_reason); + free(r->_cached_text); + if (r->events) { + for (size_t i = 0; i < r->events_count; ++i) { + free(r->events[i].type); + free(r->events[i].content); + free(r->events[i].name); + free(r->events[i].args_json); + free(r->events[i].result_json); + } + free(r->events); + } + free(r); +} + +/* Free the heap wrapper allocated by cf_session_state_get. Safe with + * NULL. */ +void cf_session_state_free(cf_session_state_t *s) { + if (!s) return; + free(s->session_id); + free(s->agent); + free(s->app_name); + free(s); +} + +/* Free the heap wrapper allocated by cf_session_list_get. Walks each + * embedded item and releases its strings before releasing the items + * array and the wrapper. Safe with NULL. */ +void cf_session_list_free(cf_session_list_t *l) { + if (!l) return; + if (l->items) { + for (size_t i = 0; i < l->items_count; ++i) { + free(l->items[i].session_id); + free(l->items[i].agent); + free(l->items[i].app_name); + } + free(l->items); + } + free(l); +} diff --git a/clients/c/tests/test_client.c b/clients/c/tests/test_client.c index d9aa430..56aadd4 100644 --- a/clients/c/tests/test_client.c +++ b/clients/c/tests/test_client.c @@ -417,7 +417,7 @@ static void reset_captures(void) { static void t_version(void) { TEST("version"); - CHECK_STR_EQ(cf_version(), "0.1.0"); + CHECK_STR_EQ(cf_version(), "0.2.0"); CHECK_STR_EQ(cf_status_str(CF_OK), "CF_OK"); CHECK_STR_EQ(cf_status_str(CF_ERR_PARSE), "CF_ERR_PARSE"); } @@ -1003,6 +1003,544 @@ static void t_cjson_bump(void) { CHECK(bad == NULL); } +/* ------------------------------------------------------------------ */ +/* v0.2 — multi-turn / Sessions */ +/* ------------------------------------------------------------------ */ + +/* Helper — JSON body for "POST /sessions" success. */ +static const char *SESS_CREATE_BODY = + "{\"ok\":true," + "\"session_id\":\"sess_abc\"," + "\"agent\":\"claude\"," + "\"created_at\":1735000000," + "\"cwd\":\"/tmp/sess_abc\"}"; + +static const char *SESS_CLOSE_BODY = "{\"ok\":true}"; + +/* Test — full create + turn round trip. */ +static void t_session_turn_round_trip(void) { + TEST("session_turn_round_trip"); + reset_captures(); + + /* 1) POST /sessions response */ + enqueue_response(200, "application/json", SESS_CREATE_BODY); + /* 2) POST /sessions//turn response with structured events */ + enqueue_response(200, "application/json", + "{\"ok\":true," + "\"session_id\":\"sess_abc\"," + "\"turn_index\":1," + "\"events\":[" + "{\"type\":\"thinking\",\"content\":\"hmm...\"}," + "{\"type\":\"tool_call\",\"name\":\"Read\",\"args\":{\"path\":\"README.md\"},\"result\":{\"chars\":42}}," + "{\"type\":\"text\",\"content\":\"hello, \"}," + "{\"type\":\"text\",\"content\":\"world\"}" + "]," + "\"stop_reason\":\"end_turn\"," + "\"duration_ms\":4321}"); + + cf_client_t *c = cf_client_new(make_base_url(), "cf_apptok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + + cf_session_options_t sopts = { .agent = "claude" }; + cf_status_t rc = cf_session_new(c, &sopts, &s, &err); + CHECK(rc == CF_OK); + CHECK(s != NULL); + if (s) { + CHECK_STR_EQ(cf_session_id(s), "sess_abc"); + CHECK_STR_EQ(cf_session_agent(s), "claude"); + } + + /* Verify the create POST */ + CHECK_STR_EQ(captured[0].method, "POST"); + CHECK_STR_EQ(captured[0].path, "/sessions"); + CHECK_STR_EQ(captured[0].auth, "Bearer cf_apptok"); + cJSON *sent = cJSON_Parse(captured[0].body); + CHECK(sent != NULL); + if (sent) { + cJSON *jagent = cJSON_GetObjectItemCaseSensitive(sent, "agent"); + CHECK(cJSON_IsString(jagent)); + if (cJSON_IsString(jagent)) CHECK_STR_EQ(jagent->valuestring, "claude"); + cJSON_Delete(sent); + } + + /* Now run a turn */ + cf_turn_options_t topts = {0}; + cf_turn_result_t *r = NULL; + rc = cf_session_turn(s, "Read README.md and summarize", &topts, &r, &err); + CHECK(rc == CF_OK); + CHECK(r != NULL); + if (r) { + CHECK(r->ok == 1); + CHECK_STR_EQ(r->session_id, "sess_abc"); + CHECK(r->turn_index == 1); + CHECK_STR_EQ(r->stop_reason, "end_turn"); + CHECK(r->duration_ms == 4321); + CHECK(r->events_count == 4); + if (r->events_count == 4) { + CHECK_STR_EQ(r->events[0].type, "thinking"); + CHECK_STR_EQ(r->events[0].content, "hmm..."); + CHECK_STR_EQ(r->events[1].type, "tool_call"); + CHECK_STR_EQ(r->events[1].name, "Read"); + CHECK(r->events[1].args_json != NULL); + CHECK(r->events[1].result_json != NULL); + if (r->events[1].args_json) { + CHECK(strstr(r->events[1].args_json, "README.md") != NULL); + } + CHECK_STR_EQ(r->events[2].type, "text"); + CHECK_STR_EQ(r->events[3].type, "text"); + } + + /* cf_turn_result_text should concatenate text events. */ + const char *text = cf_turn_result_text(r); + CHECK_STR_EQ(text, "hello, world"); + /* Second call — must reuse the cached buffer. */ + const char *text2 = cf_turn_result_text(r); + CHECK(text == text2); + + cf_turn_result_free(r); + } + + /* Verify the turn POST */ + CHECK_STR_EQ(captured[1].method, "POST"); + CHECK_STR_EQ(captured[1].path, "/sessions/sess_abc/turn"); + CHECK_STR_EQ(captured[1].auth, "Bearer cf_apptok"); + sent = cJSON_Parse(captured[1].body); + CHECK(sent != NULL); + if (sent) { + cJSON *jp = cJSON_GetObjectItemCaseSensitive(sent, "prompt"); + CHECK(cJSON_IsString(jp)); + if (cJSON_IsString(jp)) CHECK_STR_EQ(jp->valuestring, "Read README.md and summarize"); + cJSON_Delete(sent); + } + + /* Now close the session — DELETE /sessions/ */ + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + rc = cf_session_close(s, &err); + CHECK(rc == CF_OK); + CHECK_STR_EQ(captured[2].method, "DELETE"); + CHECK_STR_EQ(captured[2].path, "/sessions/sess_abc"); + + cf_session_free(s); /* already closed; no extra HTTP traffic */ + + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — close × 2 only hits DELETE once. */ +static void t_session_close_idempotent(void) { + TEST("session_close_idempotent"); + reset_captures(); + + enqueue_response(200, "application/json", SESS_CREATE_BODY); + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + /* Note: NOT enqueueing a second DELETE response — the second close + * should short-circuit before hitting the wire. If it did go to + * the wire, the test server's fallback 500 would surface as a + * non-CF_OK from the second close. */ + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + + CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK); + + CHECK(cf_session_close(s, &err) == CF_OK); + CHECK(cf_session_close(s, &err) == CF_OK); /* idempotent — no HTTP */ + CHECK(cf_session_close(s, &err) == CF_OK); + + /* Captured: POST /sessions, then exactly ONE DELETE. */ + CHECK(captured_count == 2); + if (captured_count >= 2) { + CHECK_STR_EQ(captured[0].method, "POST"); + CHECK_STR_EQ(captured[1].method, "DELETE"); + } + + cf_session_free(s); + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — turn after close → CF_ERR_USAGE, no HTTP traffic. */ +static void t_session_turn_after_close_returns_error(void) { + TEST("session_turn_after_close_returns_error"); + reset_captures(); + + enqueue_response(200, "application/json", SESS_CREATE_BODY); + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + + CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK); + CHECK(cf_session_close(s, &err) == CF_OK); + + cf_turn_options_t topts = {0}; + cf_turn_result_t *r = NULL; + cf_status_t rc = cf_session_turn(s, "hi", &topts, &r, &err); + CHECK(rc == CF_ERR_USAGE); + CHECK(r == NULL); + CHECK(err.message && strstr(err.message, "closed") != NULL); + + /* And NO third HTTP request was made. */ + CHECK(captured_count == 2); + + cf_error_free(&err); + cf_session_free(s); + cf_client_free(c); +} + +/* Test — server 404 on cross-token access surfaces as CF_ERR_API + 404. */ +static void t_session_cross_token_returns_404(void) { + TEST("session_cross_token_returns_404"); + reset_captures(); + + enqueue_response(404, "application/json", "{\"detail\":\"session not found\"}"); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_state_t *state = NULL; + cf_error_t err = {0}; + cf_status_t rc = cf_session_state_get(c, "sess_other", &state, &err); + CHECK(rc == CF_ERR_API); + CHECK(err.http_status == 404); + CHECK(state == NULL); + CHECK(err.message && strstr(err.message, "session not found") != NULL); + /* Bearer must NOT leak into the error. */ + CHECK(err.message && strstr(err.message, "tok") == NULL); + CHECK(err.message && strstr(err.message, "Bearer") == NULL); + + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — session_id with traversal characters → CF_ERR_USAGE pre-network. */ +static void t_session_validates_id_traversal(void) { + TEST("session_validates_id_traversal"); + cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok"); + cf_error_t err = {0}; + + cf_session_state_t *state = NULL; + cf_status_t rc = cf_session_state_get(c, "a/../healthz", &state, &err); + CHECK(rc == CF_ERR_USAGE); + CHECK(state == NULL); + cf_error_free(&err); + + rc = cf_session_state_get(c, "../etc/passwd", &state, &err); + CHECK(rc == CF_ERR_USAGE); + cf_error_free(&err); + + rc = cf_session_state_get(c, "", &state, &err); + CHECK(rc == CF_ERR_USAGE); + cf_error_free(&err); + + /* A valid id continues to the network and surfaces CF_ERR_TRANSPORT + * (no server listening at :1) — proving the validator passed. */ + rc = cf_session_state_get(c, "valid_id-123", &state, &err); + CHECK(rc == CF_ERR_TRANSPORT); + cf_error_free(&err); + + cf_client_free(c); +} + +/* Test — cf_session_list_get parses 2 rows and surfaces fields. */ +static void t_session_list_get(void) { + TEST("session_list_get"); + reset_captures(); + + enqueue_response(200, "application/json", + "{\"ok\":true,\"count\":2,\"sessions\":[" + "{\"session_id\":\"sess_a\",\"app_name\":\"app1\",\"agent\":\"claude\"," + "\"created_at\":1700000000,\"last_turn_at\":1700000100," + "\"turn_count\":3,\"closed_at\":null}," + "{\"session_id\":\"sess_b\",\"app_name\":\"app1\",\"agent\":\"claude\"," + "\"created_at\":1700000200,\"last_turn_at\":null," + "\"turn_count\":0,\"closed_at\":1700000300}" + "]}"); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_list_t *list = NULL; + cf_error_t err = {0}; + cf_status_t rc = cf_session_list_get(c, &list, &err); + CHECK(rc == CF_OK); + CHECK(list != NULL); + if (list) { + CHECK(list->items_count == 2); + if (list->items_count == 2) { + CHECK_STR_EQ(list->items[0].session_id, "sess_a"); + CHECK_STR_EQ(list->items[0].app_name, "app1"); + CHECK_STR_EQ(list->items[0].agent, "claude"); + CHECK(list->items[0].created_at == 1700000000); + CHECK(list->items[0].last_turn_at == 1700000100); + CHECK(list->items[0].turn_count == 3); + CHECK(list->items[0].closed_at == -1); + + CHECK_STR_EQ(list->items[1].session_id, "sess_b"); + CHECK(list->items[1].last_turn_at == -1); /* null */ + CHECK(list->items[1].closed_at == 1700000300); + } + cf_session_list_free(list); + } + + CHECK_STR_EQ(captured[0].method, "GET"); + CHECK_STR_EQ(captured[0].path, "/sessions"); + + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — cf_session_state_get returns a populated state struct. */ +static void t_session_state_get(void) { + TEST("session_state_get"); + reset_captures(); + enqueue_response(200, "application/json", + "{\"ok\":true,\"session_id\":\"sess_a\",\"app_name\":\"app1\"," + "\"agent\":\"claude\",\"created_at\":1700000000," + "\"last_turn_at\":1700000100,\"turn_count\":5,\"closed_at\":null}"); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_state_t *state = NULL; + cf_error_t err = {0}; + cf_status_t rc = cf_session_state_get(c, "sess_a", &state, &err); + CHECK(rc == CF_OK); + CHECK(state != NULL); + if (state) { + CHECK_STR_EQ(state->session_id, "sess_a"); + CHECK_STR_EQ(state->app_name, "app1"); + CHECK_STR_EQ(state->agent, "claude"); + CHECK(state->created_at == 1700000000); + CHECK(state->last_turn_at == 1700000100); + CHECK(state->turn_count == 5); + CHECK(state->closed_at == -1); /* null */ + cf_session_state_free(state); + } + + CHECK_STR_EQ(captured[0].method, "GET"); + CHECK_STR_EQ(captured[0].path, "/sessions/sess_a"); + + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — text concatenation skips non-text events; cached on second call. */ +static void t_turn_result_text_concatenates(void) { + TEST("turn_result_text_concatenates"); + reset_captures(); + enqueue_response(200, "application/json", SESS_CREATE_BODY); + enqueue_response(200, "application/json", + "{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0," + "\"events\":[" + "{\"type\":\"text\",\"content\":\"alpha \"}," + "{\"type\":\"thinking\",\"content\":\"ignored\"}," + "{\"type\":\"text\",\"content\":\"beta\"}," + "{\"type\":\"text\"}," /* no content — skipped */ + "{\"type\":\"text\",\"content\":\"\"}," /* empty — appends nothing */ + "{\"type\":\"text\",\"content\":\" gamma\"}" + "]," + "\"stop_reason\":\"end_turn\",\"duration_ms\":1}"); + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK); + + cf_turn_result_t *r = NULL; + cf_turn_options_t topts = {0}; + CHECK(cf_session_turn(s, "go", &topts, &r, &err) == CF_OK); + if (r) { + CHECK_STR_EQ(cf_turn_result_text(r), "alpha beta gamma"); + /* Empty-text edge case is handled. */ + cf_turn_result_free(r); + } + + cf_session_close(s, &err); + cf_session_free(s); + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — cf_session_free(NULL) and double-free pattern is safe. */ +static void t_session_free_idempotent(void) { + TEST("session_free_idempotent"); + cf_session_free(NULL); /* must not crash */ + + /* For an open session, free should attempt close. With no server + * listening, the close fails internally but cf_session_free swallows + * it — must not crash, must not leak. */ + cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok"); + /* Verify the other v0.2 freers are also NULL-safe. */ + cf_turn_result_free(NULL); + cf_session_state_free(NULL); + cf_session_list_free(NULL); + + cf_client_free(c); + CHECK(1); /* survival = pass */ +} + +/* Test — turn-result memory is fully released (valgrind catches leaks). */ +static void t_turn_result_memory_clean(void) { + TEST("turn_result_memory_clean"); + reset_captures(); + enqueue_response(200, "application/json", SESS_CREATE_BODY); + enqueue_response(200, "application/json", + "{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":7," + "\"events\":[" + "{\"type\":\"text\",\"content\":\"a\"}," + "{\"type\":\"tool_call\",\"name\":\"Bash\"," + "\"args\":{\"cmd\":\"ls\"},\"result\":{\"out\":\"foo\\nbar\"}}," + "{\"type\":\"thinking\",\"content\":\"...\"}" + "]," + "\"stop_reason\":\"end_turn\",\"duration_ms\":99}"); + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK); + + cf_turn_options_t topts = {0}; + cf_turn_result_t *r = NULL; + CHECK(cf_session_turn(s, "x", &topts, &r, &err) == CF_OK); + /* Touch text twice to populate + reuse the cache. */ + CHECK_STR_EQ(cf_turn_result_text(r), "a"); + CHECK_STR_EQ(cf_turn_result_text(r), "a"); + cf_turn_result_free(r); + + cf_session_close(s, &err); + cf_session_free(s); + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — turn with files passes the array. */ +static void t_session_turn_with_files(void) { + TEST("session_turn_with_files"); + reset_captures(); + enqueue_response(200, "application/json", SESS_CREATE_BODY); + enqueue_response(200, "application/json", + "{\"ok\":true,\"session_id\":\"sess_abc\",\"turn_index\":0," + "\"events\":[],\"stop_reason\":\"end_turn\",\"duration_ms\":1}"); + enqueue_response(200, "application/json", SESS_CLOSE_BODY); + + cf_client_t *c = cf_client_new(make_base_url(), "tok"); + cf_session_t *s = NULL; + cf_error_t err = {0}; + CHECK(cf_session_new(c, NULL, &s, &err) == CF_OK); + + const char *files[] = { "ff_aaa", "ff_bbb" }; + cf_turn_options_t topts = { + .files = files, + .files_count = 2, + .timeout_secs = 45, + }; + cf_turn_result_t *r = NULL; + CHECK(cf_session_turn(s, "process these", &topts, &r, &err) == CF_OK); + + cJSON *sent = cJSON_Parse(captured[1].body); + CHECK(sent != NULL); + if (sent) { + cJSON *farr = cJSON_GetObjectItemCaseSensitive(sent, "files"); + CHECK(cJSON_IsArray(farr)); + if (cJSON_IsArray(farr)) { + CHECK(cJSON_GetArraySize(farr) == 2); + } + cJSON *jto = cJSON_GetObjectItemCaseSensitive(sent, "timeout_secs"); + CHECK(cJSON_IsNumber(jto)); + if (cJSON_IsNumber(jto)) CHECK((int)jto->valuedouble == 45); + cJSON_Delete(sent); + } + + cf_turn_result_free(r); + cf_session_close(s, &err); + cf_session_free(s); + cf_error_free(&err); + cf_client_free(c); +} + +/* Test — bearer token never appears in any cf_error_t.message. */ +static void t_session_bearer_never_leaks(void) { + TEST("session_bearer_never_leaks"); + /* Guard against a regression where a future refactor concatenates + * the auth header into a transport / parse error. We try every + * fallible session entry point with a distinctive bearer string + * and confirm the bearer never appears in the resulting message. */ + static const char *SECRET = "BEARERMARKER_zzz_9999"; + + cf_client_t *c = cf_client_new("http://127.0.0.1:1", SECRET); + cf_error_t err = {0}; + + /* (a) cf_session_new — server unreachable */ + cf_session_t *s = NULL; + cf_session_options_t sopts = { .agent = "claude" }; + cf_session_new(c, &sopts, &s, &err); + CHECK(err.message != NULL); + if (err.message) CHECK(strstr(err.message, SECRET) == NULL); + cf_error_free(&err); + + /* (b) cf_session_state_get — server returns 404 with detail field */ + reset_captures(); + /* Reset client to point at the mock server. */ + cf_client_free(c); + c = cf_client_new(make_base_url(), SECRET); + enqueue_response(404, "application/json", + "{\"detail\":\"session not found\"}"); + cf_session_state_t *state = NULL; + cf_session_state_get(c, "sess_xyz", &state, &err); + CHECK(err.message != NULL); + if (err.message) { + CHECK(strstr(err.message, SECRET) == NULL); + CHECK(strstr(err.message, "Bearer") == NULL); + } + cf_error_free(&err); + + /* (c) cf_session_list_get — 401 with detail */ + enqueue_response(401, "application/json", + "{\"detail\":\"invalid token\"}"); + cf_session_list_t *list = NULL; + cf_session_list_get(c, &list, &err); + CHECK(err.message != NULL); + if (err.message) CHECK(strstr(err.message, SECRET) == NULL); + cf_error_free(&err); + + cf_client_free(c); +} + +/* Test — null-arg defenses on every session entry point. */ +static void t_session_null_arg_defenses(void) { + TEST("session_null_arg_defenses"); + cf_error_t err = {0}; + cf_session_t *s = NULL; + cf_turn_result_t *r = NULL; + cf_session_state_t *state = NULL; + + CHECK(cf_session_new(NULL, NULL, &s, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + cf_client_t *c = cf_client_new("http://127.0.0.1:1", "tok"); + CHECK(cf_session_new(c, NULL, NULL, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + CHECK(cf_session_turn(NULL, "p", NULL, &r, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + CHECK(cf_session_close(NULL, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + CHECK(cf_session_state_get(c, NULL, &state, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + CHECK(cf_session_list_get(c, NULL, &err) == CF_ERR_USAGE); + cf_error_free(&err); + + /* accessors safe on NULL */ + CHECK(cf_session_id(NULL) == NULL); + CHECK(cf_session_agent(NULL) == NULL); + CHECK(strcmp(cf_turn_result_text(NULL), "") == 0); + + cf_client_free(c); +} + /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ @@ -1040,6 +1578,21 @@ int main(void) { t_concurrent_client_init(); t_cjson_bump(); + /* v0.2 — multi-turn / sessions */ + t_session_turn_round_trip(); + t_session_close_idempotent(); + t_session_turn_after_close_returns_error(); + t_session_cross_token_returns_404(); + t_session_validates_id_traversal(); + t_session_list_get(); + t_session_state_get(); + t_turn_result_text_concatenates(); + t_session_free_idempotent(); + t_turn_result_memory_clean(); + t_session_turn_with_files(); + t_session_bearer_never_leaks(); + t_session_null_arg_defenses(); + stop_server(); fprintf(stderr, "\n%d tests, %d failures\n", g_tests, g_failures);