clients/c: v0.2 multi-turn Session API
- cf_session_t (opaque), cf_session_options_t, cf_turn_options_t,
cf_turn_event_t, cf_turn_result_t, cf_session_state_t, cf_session_list_t
- cf_session_new / _turn / _close (idempotent) / _free, _state_get,
_list_get, plus cf_session_id / _agent accessors
- cf_turn_result_text concatenates type=="text" events into a single
string, lazily cached, OWNED by the result (caller must not free)
- Session-id allow-list ([A-Za-z0-9_-]+) validated client-side everywhere
it lands in the URL path — same pattern as cf_admin_revoke_token, no
reverse-proxy traversal foothold
- Bearer hygiene preserved: regression test covers cf_session_new /
state_get / list_get and asserts the bearer never lands in
cf_error_t.message
- v0.1 surface unchanged. Existing types (cf_client_t, cf_run_*, etc.)
are byte-identical. cJSON 1.7.18 preserved.
Tests (21 → 34, +13):
- session_turn_round_trip — full create + turn + close, event parsing
- session_close_idempotent — close × 3, DELETE on the wire ONCE
- session_turn_after_close_returns_error — CF_ERR_USAGE, no HTTP
- session_cross_token_returns_404 — CF_ERR_API + http_status==404
- session_validates_id_traversal — "a/../healthz" rejected pre-network
- session_list_get — 2-row mock parsed; null last_turn_at → -1
- session_state_get — full state shape parsed
- turn_result_text_concatenates — skips non-text + empty content
- session_free_idempotent — NULL-safe across all v0.2 freers
- turn_result_memory_clean — valgrind regression guard
- session_turn_with_files — files + timeout_secs serialised
- session_bearer_never_leaks — 3 fallible paths, bearer stays out
- session_null_arg_defenses — every entry point rejects NULLs
Verification:
- cmake --build build (Release, -Werror -Wall -Wextra -Wpedantic
-Wshadow): clean
- ctest --test-dir build: 34/34 pass
- valgrind --leak-check=full: 12,678 allocs == 12,678 frees, 0 errors,
0 leaks
- ASan build: 34/34 pass clean
- UBSan build (-fsanitize=undefined -fno-sanitize-recover=all): 34/34
pass clean
README adds "Multi-turn / Sessions (v0.2)" section: lifecycle example,
idempotent-close note, event/text ownership rules, state + listing,
memory ownership table, what's not in v0.2.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
parent
8479725513
commit
22e57e3dad
6 changed files with 1508 additions and 8 deletions
|
|
@ -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/<id>/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/<id> */
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue