diff --git a/Makefile b/Makefile index 07934cb..742d603 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ SRCS = \ $(SRC_DIR)/tools/tool_memory.c \ $(SRC_DIR)/tools/tool_config.c \ $(SRC_DIR)/tools/tool_cashu_wallet.c \ + $(SRC_DIR)/tools/tool_blossom.c \ $(SRC_DIR)/trigger_manager.c \ $(SRC_DIR)/prompt_template.c \ $(SRC_DIR)/http_api.c \ diff --git a/README.md b/README.md index 8d90069..debd70b 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.1.19 +## Current Status — v0.1.20 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.1.19 — Prevent ignored spent token events from reloading into wallet balance after restart +> Last release update: v0.1.20 — Complete Blossom hardening and validation: HTTPS-only Blossom tools, config-driven upload/download limits, overwrite protection, CA bundle unification via nostr_http, and comprehensive live+mock nostr_http/blossom test coverage - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/config.jsonc.example b/config.jsonc.example index 7d50974..5680279 100644 --- a/config.jsonc.example +++ b/config.jsonc.example @@ -62,6 +62,8 @@ "stall_repeat_threshold": 3, // stop early when identical tool-call turns repeat this many times "local_http_fetch_default_timeout_seconds": 20, "local_http_fetch_max_timeout_seconds": 120, + "blossom_max_upload_bytes": 16777216, + "blossom_max_download_bytes": 16777216, "shell": { "enabled": true, "timeout_seconds": 30, diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ef99981..7825023 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -172,6 +172,16 @@ These tools manage the agent's short-term and long-term memory, persisted on Nos | `cashu_wallet_send_token` | Create an outbound ecash token from wallet proofs and return a `cashuA`/`cashuB` token string | | `cashu_wallet_mints_set` | Set wallet mints (NIP-60), public mints (NIP-61), or both | +### Blossom Tools + +| Tool | Description | +|---|---| +| `blossom_upload` | Upload a local file to a Blossom server and return blob metadata | +| `blossom_download` | Download a Blossom blob to a local file path | +| `blossom_head` | Fetch Blossom blob metadata by SHA-256 | +| `blossom_delete` | Delete a Blossom blob by SHA-256 using signed auth | +| `blossom_list` | List Blossom blobs for a pubkey (defaults to agent pubkey) | + ### Content Publishing Conveniences | Tool | Description | diff --git a/plans/blossom_tools.md b/plans/blossom_tools.md new file mode 100644 index 0000000..d5b527c --- /dev/null +++ b/plans/blossom_tools.md @@ -0,0 +1,668 @@ +# Implementation Plan: Blossom Tools for Didactyl + +## Objective + +Add first-class Blossom tooling so the agent can upload, download, inspect, and delete blobs on Blossom-compatible servers. As a prerequisite, consolidate all HTTP client code into a single public API in `nostr_core_lib` so that both projects share one curl implementation. + +--- + +## Problem: HTTP Client Duplication + +There are currently **5 separate curl implementations** across the two projects: + +| # | Project | File | Function | Methods | Visibility | +|---|---|---|---|---|---| +| 1 | nostr_core_lib | `cashu_mint.c:88` | `cashu_http_json_request()` | GET, POST | `static` | +| 2 | nostr_core_lib | `nip005.c:103` | `nip05_http_get()` | GET | `static` | +| 3 | nostr_core_lib | `nip011.c:344` | inline curl block | GET | `static` | +| 4 | Didactyl | `llm.c:88` | `perform_http_request()` | GET, POST | `static` | +| 5 | Didactyl | `tool_local.c:214` | inline in `execute_local_http_fetch()` | GET, POST, any | `static` | + +Each has its own write callback, response buffer struct, CA bundle detection, SSL config, and error handling. Adding Blossom without consolidation would create a 6th copy. + +### Decision + +Consolidate all HTTP client code into a **single public API** in `nostr_core_lib`. Migrate all consumers in both projects to use it. Then build Blossom client on top of the same shared HTTP layer. + +--- + +## Phase 1: Unified HTTP Client in nostr_core_lib + +### New files + +- `nostr_core_lib/nostr_core/nostr_http.h` — public API +- `nostr_core_lib/nostr_core/nostr_http.c` — single curl implementation + +### Proposed API + +```c +#ifndef NOSTR_HTTP_H +#define NOSTR_HTTP_H + +#include + +// HTTP response container +typedef struct { + char* body; // Response body (malloc'd, caller frees) + size_t body_len; // Body length in bytes + long status_code; // HTTP status code + char* content_type; // Content-Type header value (malloc'd, caller frees) + char* headers_raw; // All response headers (malloc'd, caller frees, optional) +} nostr_http_response_t; + +// HTTP request options +typedef struct { + const char* method; // "GET", "POST", "PUT", "DELETE", "HEAD" (default: "GET") + const char* url; // Required + const char** headers; // NULL-terminated array of "Key: Value" strings (optional) + const unsigned char* body; // Request body bytes (optional) + size_t body_len; // Body length (0 if no body) + int timeout_seconds; // Request timeout (default: 30) + size_t max_response_bytes; // Cap response body size (0 = unlimited) + int follow_redirects; // 1 = follow, 0 = don't (default: 1) + int max_redirects; // Max redirect hops (default: 3) + const char* user_agent; // User-Agent header (default: "nostr-core/VERSION") + int capture_headers; // 1 = capture response headers in headers_raw +} nostr_http_request_t; + +// Set global CA bundle path for all HTTP requests +void nostr_http_set_ca_bundle(const char* ca_bundle_path); + +// Auto-detect CA bundle from common system paths +const char* nostr_http_detect_ca_bundle(void); + +// Perform an HTTP request +// Returns NOSTR_SUCCESS on successful HTTP round-trip (even 4xx/5xx). +// Returns NOSTR_ERROR_NETWORK_FAILED on connection/DNS/timeout failure. +// Caller must call nostr_http_response_free() on success. +int nostr_http_request(const nostr_http_request_t* req, nostr_http_response_t* resp); + +// Free response resources +void nostr_http_response_free(nostr_http_response_t* resp); + +// Convenience: simple GET returning body string +int nostr_http_get(const char* url, int timeout_seconds, char** body_out, long* status_out); + +// Convenience: JSON POST returning body string +int nostr_http_post_json(const char* url, const char* json_body, int timeout_seconds, + char** body_out, long* status_out); + +#endif +``` + +### Key design decisions + +1. **Binary-safe body** — `body` is `unsigned char*` with explicit `body_len`, supporting both JSON text and raw file uploads +2. **Method-agnostic** — supports GET, POST, PUT, DELETE, HEAD, PATCH via string +3. **Response size cap** — `max_response_bytes` prevents OOM on large downloads +4. **Header capture** — optional `capture_headers` for HEAD requests (Blossom needs this) +5. **CA bundle** — single global setter replaces 5 separate detection functions +6. **Convenience wrappers** — `nostr_http_get()` and `nostr_http_post_json()` cover the common JSON API pattern used by cashu_mint, nip005, nip011 + +### Implementation notes + +- Single `static size_t write_callback()` function +- Single `static size_t header_callback()` function (for header capture) +- CA bundle auto-detection consolidated from the 5 existing implementations +- SSL verification always on by default + +--- + +## Phase 2: Migrate nostr_core_lib Internal Consumers + +### cashu_mint.c + +Replace `cashu_http_json_request()` (static, ~70 lines) with calls to `nostr_http_post_json()` / `nostr_http_get()`. + +**Before:** +```c +static int cashu_http_json_request(const char* method, const char* url, + const char* body, int timeout_seconds, + char** response_out, long* status_out) { + // 70 lines of curl boilerplate +} +``` + +**After:** +```c +static int cashu_http_json_request(const char* method, const char* url, + const char* body, int timeout_seconds, + char** response_out, long* status_out) { + if (strcmp(method, "POST") == 0) { + return nostr_http_post_json(url, body, timeout_seconds, response_out, status_out); + } + return nostr_http_get(url, timeout_seconds, response_out, status_out); +} +``` + +The function signature stays the same so all 20+ call sites in cashu_mint.c are unaffected. + +### nip005.c + +Replace `nip05_http_get()` (static, ~50 lines) with `nostr_http_get()`. + +### nip011.c + +Replace inline curl block (~40 lines) with `nostr_http_request()` using custom Accept header. + +### Build changes + +- Add `nostr_http.c` to the nostr_core_lib build +- Remove `#include ` from cashu_mint.c, nip005.c, nip011.c (only nostr_http.c includes it) +- Update `nostr_core.h` to include `nostr_http.h` + +--- + +## Phase 3: Migrate Didactyl Consumers + +### llm.c + +Replace `perform_http_request()` (static, ~80 lines) with `nostr_http_request()`. + +**Current signature:** `static char* perform_http_request(const char* url, const char* body, int is_post)` + +**Migration:** Build a `nostr_http_request_t` with the Authorization Bearer header, call `nostr_http_request()`, extract body. The LLM-specific logic (WebSocket URL detection, debug logging, status code handling) stays in llm.c — only the curl plumbing moves out. + +Also remove: +- `static size_t write_cb()` — replaced by nostr_http's callback +- `static const char* detect_ca_bundle_path()` — replaced by `nostr_http_detect_ca_bundle()` +- `typedef struct { char* data; size_t len; } response_buffer_t;` — replaced by `nostr_http_response_t` + +### tool_local.c + +Replace the inline curl block in `execute_local_http_fetch()` (~100 lines) with `nostr_http_request()`. + +Also remove: +- `static size_t local_http_fetch_write_cb_local()` — replaced +- `static const char* detect_ca_bundle_path_for_tools_local()` — replaced +- `typedef struct { ... } local_http_fetch_buffer_t;` — replaced + +### cashu_wallet.c + +Remove `detect_ca_bundle_path_for_cashu_wallet()` — replaced by `nostr_http_detect_ca_bundle()` called once at init. + +The `cashu_mint_set_ca_bundle()` call at line 423 becomes `nostr_http_set_ca_bundle()` called once in main.c startup. + +### Build changes + +- Remove `#include ` from llm.c and tool_local.c +- Didactyl only includes curl transitively through nostr_core_lib +- CA bundle detection happens once in main.c startup via `nostr_http_set_ca_bundle(nostr_http_detect_ca_bundle())` + +### Verification + +After migration, grep confirms zero direct curl usage in Didactyl: +```bash +grep -r "curl_easy_init\|CURL\s*\*\|curl_easy_setopt" src/ +# Expected: no results +``` + +--- + +## Phase 4: Blossom Client in nostr_core_lib + +### New files + +- `nostr_core_lib/nostr_core/blossom_client.h` — public Blossom API +- `nostr_core_lib/nostr_core/blossom_client.c` — implementation using `nostr_http` + +### Proposed API + +```c +#ifndef NOSTR_BLOSSOM_CLIENT_H +#define NOSTR_BLOSSOM_CLIENT_H + +#include "nostr_common.h" +#include "../cjson/cJSON.h" +#include + +// Blob descriptor returned by Blossom servers +typedef struct { + char sha256[65]; // Hex-encoded SHA-256 hash + char url[512]; // Canonical blob URL + long size; // Blob size in bytes + char content_type[128]; // MIME type + long created; // Unix timestamp +} blossom_blob_descriptor_t; + +// Set CA bundle for Blossom HTTP requests (delegates to nostr_http) +void blossom_set_ca_bundle(const char* ca_bundle_path); + +// Create a kind 24242 Blossom authorization event +// Returns base64-encoded signed event string for Authorization header. +// Caller must free() the returned string. +char* blossom_create_auth_header(const unsigned char* private_key, + const char* operation, // "upload", "delete", "list" + const char* sha256_hex, // blob hash (NULL for list) + int expiration_seconds); + +// Upload file bytes to a Blossom server +// Returns NOSTR_SUCCESS and fills descriptor on success. +int blossom_upload(const char* server_url, + const unsigned char* data, + size_t data_len, + const char* content_type, + const unsigned char* private_key, // for auth event (NULL = no auth) + const char* sha256_hex, // pre-computed hash (NULL = compute) + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +// Upload a local file to a Blossom server +int blossom_upload_file(const char* server_url, + const char* file_path, + const char* content_type, + const unsigned char* private_key, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +// Download a blob by SHA-256 hash +// Returns NOSTR_SUCCESS and fills body_out/body_len_out. +// Caller must free(*body_out). +int blossom_download(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + size_t max_bytes, + unsigned char** body_out, + size_t* body_len_out, + char* content_type_out, // buffer, at least 128 bytes + size_t content_type_out_size); + +// Download a blob to a local file +int blossom_download_to_file(const char* server_url, + const char* sha256_hex, + const char* output_path, + int timeout_seconds, + size_t max_bytes, + blossom_blob_descriptor_t* descriptor_out); + +// HEAD request — check blob existence and metadata +int blossom_head(const char* server_url, + const char* sha256_hex, + int timeout_seconds, + blossom_blob_descriptor_t* descriptor_out); + +// Delete a blob by SHA-256 hash (requires auth) +int blossom_delete(const char* server_url, + const char* sha256_hex, + const unsigned char* private_key, + int timeout_seconds); + +// List blobs for a pubkey +// Returns NOSTR_SUCCESS and fills descriptors array. +// Caller must free(*descriptors_out). +int blossom_list(const char* server_url, + const char* pubkey_hex, + int timeout_seconds, + blossom_blob_descriptor_t** descriptors_out, + int* count_out); + +#endif +``` + +### Implementation details + +- `blossom_create_auth_header()` uses `nostr_create_and_sign_event()` (kind 24242) + `base64_encode()` +- All HTTP calls go through `nostr_http_request()` — no direct curl usage +- `blossom_upload_file()` uses `nostr_sha256_file_stream()` to hash before upload +- `blossom_download_to_file()` verifies SHA-256 after download + +### Build changes + +- Add `blossom_client.c` to nostr_core_lib build +- Update `nostr_core.h` to include `blossom_client.h` +- Rebuild `libnostr_core_*.a` + +--- + +## Phase 5: Blossom Tools in Didactyl + +### New file + +- `src/tools/tool_blossom.c` — thin tool wrappers calling `blossom_*()` from nostr_core_lib + +### Tool set + +| Tool | Description | Library Function | +|---|---|---| +| `blossom_upload` | Upload local file to Blossom server | `blossom_upload_file()` | +| `blossom_download` | Download blob to local file | `blossom_download_to_file()` | +| `blossom_head` | Check blob existence and metadata | `blossom_head()` | +| `blossom_delete` | Delete blob from server | `blossom_delete()` | +| `blossom_list` | List blobs by pubkey | `blossom_list()` | + +### Tool schemas + +(Unchanged from original plan — see Tool Contracts section below.) + +### Integration points + +1. **`tools_internal.h`** — add `execute_blossom_*()` prototypes +2. **`tools_dispatch.c`** — add `strcmp` branches +3. **`tools_schema.c`** — add OpenAI tool definitions +4. **`Makefile`** — add `$(SRC_DIR)/tools/tool_blossom.c` to SRCS +5. **`docs/TOOLS.md`** — add Blossom Storage Tools section + +### Pattern + +Follows the exact same pattern as Cashu: + +``` +tool_blossom.c (arg parsing + JSON result formatting) + → blossom_client.h (nostr_core_lib - domain API) + → nostr_http.h (nostr_core_lib - shared HTTP client) +``` + +Just like: + +``` +tool_cashu_wallet.c (arg parsing + JSON result formatting) + → cashu_wallet.c (Didactyl - wallet state + Nostr persistence) + → cashu_mint.h (nostr_core_lib - domain API) + → nostr_http.h (nostr_core_lib - shared HTTP client) +``` + +--- + +## Tool Contracts + +All tools return a JSON object with at minimum: + +```json +{ + "success": true, + "error": "...optional on failure..." +} +``` + +### `blossom_upload` + +**Input:** +```json +{ + "type": "object", + "properties": { + "server": { "type": "string", "description": "Blossom server base URL; omit if default configured" }, + "file_path": { "type": "string", "description": "Relative local path inside working directory" }, + "content_type": { "type": "string", "description": "Optional MIME type override" } + }, + "required": ["file_path"] +} +``` + +**Output:** +```json +{ + "success": true, + "server": "https://blossom.example", + "sha256": "<64-hex>", + "size": 12345, + "content_type": "image/png", + "url": "https://blossom.example/" +} +``` + +### `blossom_download` + +**Input:** +```json +{ + "type": "object", + "properties": { + "server": { "type": "string" }, + "sha256": { "type": "string", "description": "Blob hash hex identifier" }, + "url": { "type": "string", "description": "Direct blob URL if server+sha256 not provided" }, + "output_path": { "type": "string", "description": "Relative path to write file" }, + "overwrite": { "type": "boolean", "default": false } + }, + "required": ["output_path"] +} +``` + +**Output:** +```json +{ + "success": true, + "output_path": "downloads/file.bin", + "bytes_written": 12345, + "sha256": "", + "verified": true, + "content_type": "application/octet-stream" +} +``` + +### `blossom_head` + +**Input:** +```json +{ + "type": "object", + "properties": { + "server": { "type": "string" }, + "sha256": { "type": "string" }, + "url": { "type": "string" } + } +} +``` + +**Output:** +```json +{ + "success": true, + "exists": true, + "sha256": "<64-hex>", + "size": 12345, + "content_type": "image/jpeg" +} +``` + +### `blossom_delete` + +**Input:** +```json +{ + "type": "object", + "properties": { + "server": { "type": "string" }, + "sha256": { "type": "string" } + }, + "required": ["sha256"] +} +``` + +**Output:** +```json +{ + "success": true, + "deleted": true, + "server": "https://blossom.example", + "sha256": "<64-hex>" +} +``` + +### `blossom_list` + +**Input:** +```json +{ + "type": "object", + "properties": { + "server": { "type": "string" }, + "pubkey": { "type": "string", "description": "Hex pubkey; defaults to agent pubkey" } + } +} +``` + +**Output:** +```json +{ + "success": true, + "server": "https://blossom.example", + "pubkey": "<64-hex>", + "blobs": [ + { "sha256": "...", "size": 12345, "content_type": "image/png", "url": "...", "created": 1679000000 } + ], + "count": 1 +} +``` + +--- + +## Validation and Safety Rules + +1. `server` must be `https://` unless explicit config allows insecure local testing +2. `sha256` must match `^[0-9a-fA-F]{64}$` +3. `file_path` and `output_path` must be safe relative paths (reuse `tool_local.c` pattern) +4. Enforce maximum upload/download size from config +5. Refuse overwrite unless `overwrite=true` +6. Normalize all errors into `{"success": false, "error": "..."}` +7. Do not leak keys/secrets in returned payload + +--- + +## Execution Order + +### Step 1: `nostr_http` in nostr_core_lib +- Create `nostr_http.h` and `nostr_http.c` +- Write unit tests for GET, POST, PUT, DELETE, HEAD +- Verify CA bundle detection works across distros + +### Step 2: Migrate nostr_core_lib consumers +- Refactor `cashu_mint.c` to use `nostr_http` +- Refactor `nip005.c` to use `nostr_http` +- Refactor `nip011.c` to use `nostr_http` +- Remove direct `#include ` from all three +- Run existing tests to verify no regressions + +### Step 3: Migrate Didactyl consumers +- Refactor `llm.c` to use `nostr_http` +- Refactor `tool_local.c` to use `nostr_http` +- Update `cashu_wallet.c` CA bundle init +- Remove all direct curl includes from Didactyl src/ +- Verify: `grep -r "curl_easy_init" src/` returns zero results +- Run existing tests + +### Step 4: `blossom_client` in nostr_core_lib +- Create `blossom_client.h` and `blossom_client.c` +- Implement auth event builder, upload, download, head, delete, list +- Write unit tests + +### Step 5: Blossom tools in Didactyl +- Create `tool_blossom.c` +- Add schemas, dispatch, prototypes +- Update docs +- Run full test suite + +--- + +## Architecture Diagram + +```mermaid +graph TD + subgraph "Didactyl Tools Layer" + TB[tool_blossom.c] + TCW[tool_cashu_wallet.c] + TL[tool_local.c] + LLM[llm.c] + end + + subgraph "Didactyl Domain Layer" + CW[cashu_wallet.c] + end + + subgraph "nostr_core_lib - Domain Clients" + BC[blossom_client.c] + CM[cashu_mint.c] + N05[nip005.c] + N11[nip011.c] + end + + subgraph "nostr_core_lib - Shared Infrastructure" + NH[nostr_http.c
Single curl implementation] + NIP1[nip001.c
Event signing] + UTIL[utils.c
SHA-256 + base64 + hex] + end + + TB --> BC + TCW --> CW + CW --> CM + TL --> NH + LLM --> NH + + BC --> NH + BC --> NIP1 + BC --> UTIL + CM --> NH + N05 --> NH + N11 --> NH +``` + +--- + +## Testing Plan + +### Unit tests +1. `nostr_http` — GET/POST/PUT/DELETE/HEAD, timeouts, max_response_bytes, CA bundle +2. `blossom_client` — auth event creation, upload/download/head/delete/list +3. Argument parsing and validation for all Blossom tools + +### Integration tests +- Mock Blossom server for upload/download/head/delete/list +- Verify SHA-256 integrity on download +- Test auth event expiration +- Test error responses (404, 401, 403, 500) + +### Regression tests +- All existing Cashu wallet tests pass after cashu_mint migration +- All existing NIP-05 and NIP-11 tests pass +- LLM requests work correctly after llm.c migration +- `local_http_fetch` tool works correctly after tool_local.c migration + +--- + +## Risks and Mitigations + +1. **nostr_core_lib API change breaks Didactyl build** + - Mitigation: version-pin nostr_core_lib; test both projects together before release + +2. **Subtle curl behavior differences after migration** + - Mitigation: keep convenience wrappers thin; run existing test suites at each step + +3. **Binary body support gaps** + - Mitigation: `nostr_http_request_t.body` is `unsigned char*` with explicit length from day one + +4. **CA bundle detection regression on specific distros** + - Mitigation: consolidate all 5 existing detection paths into one comprehensive function + +5. **Blossom server protocol variance** + - Mitigation: defensive response parsing; test against multiple server implementations + +--- + +## Files Changed Summary + +### nostr_core_lib (new) +- `nostr_core/nostr_http.h` — shared HTTP client API +- `nostr_core/nostr_http.c` — shared HTTP client implementation +- `nostr_core/blossom_client.h` — Blossom client API +- `nostr_core/blossom_client.c` — Blossom client implementation + +### nostr_core_lib (modified) +- `nostr_core/cashu_mint.c` — replace static curl with `nostr_http` +- `nostr_core/nip005.c` — replace static curl with `nostr_http` +- `nostr_core/nip011.c` — replace static curl with `nostr_http` +- `nostr_core/nostr_core.h` — add includes for new headers +- `build.sh` — add new source files + +### Didactyl (new) +- `src/tools/tool_blossom.c` — Blossom tool implementations + +### Didactyl (modified) +- `src/llm.c` — replace static curl with `nostr_http` +- `src/tools/tool_local.c` — replace static curl with `nostr_http` +- `src/cashu_wallet.c` — update CA bundle init +- `src/main.c` — add `nostr_http_set_ca_bundle()` call at startup +- `src/tools/tools_internal.h` — add Blossom prototypes +- `src/tools/tools_dispatch.c` — add Blossom dispatch +- `src/tools/tools_schema.c` — add Blossom schemas +- `Makefile` — add `tool_blossom.c` to SRCS +- `docs/TOOLS.md` — add Blossom section diff --git a/src/cashu_wallet.c b/src/cashu_wallet.c index 77b19ba..ecedda0 100644 --- a/src/cashu_wallet.c +++ b/src/cashu_wallet.c @@ -10,7 +10,6 @@ #include #include -#include #include "debug.h" #include "nostr_handler.h" @@ -45,27 +44,6 @@ typedef struct { static cashu_wallet_state_t g_wallet = {0}; -static const char* detect_ca_bundle_path_for_cashu_wallet(void) { - const char* env = getenv("SSL_CERT_FILE"); - if (env && env[0] != '\0' && access(env, R_OK) == 0) { - return env; - } - - static const char* candidates[] = { - "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu - "/etc/ssl/cert.pem", // Alpine - "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora - "/etc/ssl/ca-bundle.pem" // openSUSE - }; - - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { - if (access(candidates[i], R_OK) == 0) { - return candidates[i]; - } - } - - return NULL; -} static void wallet_clear_tokens_locked(void) { if (!g_wallet.tokens) { @@ -419,7 +397,7 @@ int cashu_wallet_init(didactyl_config_t* cfg) { g_wallet.initialized = 1; } - const char* ca_bundle = detect_ca_bundle_path_for_cashu_wallet(); + const char* ca_bundle = nostr_http_detect_ca_bundle(); cashu_mint_set_ca_bundle(ca_bundle); pthread_mutex_lock(&g_wallet.mutex); @@ -680,10 +658,6 @@ int cashu_wallet_load_from_relays(void) { continue; } - if (nostr_block_list_is_event_blocked(id->valuestring)) { - continue; - } - nostr_nip60_token_data_t token; memset(&token, 0, sizeof(token)); if (nostr_nip60_parse_token_event(ev, g_wallet.cfg->keys.private_key, &token) != 0) { diff --git a/src/config.c b/src/config.c index 93673d0..670a981 100644 --- a/src/config.c +++ b/src/config.c @@ -230,6 +230,10 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds"); cJSON* local_http_fetch_max_timeout_seconds = cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_max_timeout_seconds"); + cJSON* blossom_max_upload_bytes = + cJSON_GetObjectItemCaseSensitive(tools, "blossom_max_upload_bytes"); + cJSON* blossom_max_download_bytes = + cJSON_GetObjectItemCaseSensitive(tools, "blossom_max_download_bytes"); if (enabled && cJSON_IsBool(enabled)) { config->tools.enabled = cJSON_IsTrue(enabled) ? 1 : 0; } @@ -256,6 +260,12 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { config->tools.local_http_fetch_max_timeout_seconds = (int)local_http_fetch_max_timeout_seconds->valuedouble; } + if (blossom_max_upload_bytes && cJSON_IsNumber(blossom_max_upload_bytes)) { + config->tools.blossom_max_upload_bytes = (int)blossom_max_upload_bytes->valuedouble; + } + if (blossom_max_download_bytes && cJSON_IsNumber(blossom_max_download_bytes)) { + config->tools.blossom_max_download_bytes = (int)blossom_max_download_bytes->valuedouble; + } cJSON* shell = cJSON_GetObjectItemCaseSensitive(tools, "shell"); if (!shell || !cJSON_IsObject(shell)) { @@ -310,6 +320,12 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { if (config->tools.local_http_fetch_default_timeout_seconds > config->tools.local_http_fetch_max_timeout_seconds) { config->tools.local_http_fetch_default_timeout_seconds = config->tools.local_http_fetch_max_timeout_seconds; } + if (config->tools.blossom_max_upload_bytes < 1024) { + config->tools.blossom_max_upload_bytes = 16 * 1024 * 1024; + } + if (config->tools.blossom_max_download_bytes < 1024) { + config->tools.blossom_max_download_bytes = 16 * 1024 * 1024; + } return 0; } @@ -1231,6 +1247,8 @@ int config_load(const char* path, didactyl_config_t* config) { config->tools.stall_repeat_threshold = 3; config->tools.local_http_fetch_default_timeout_seconds = 20; config->tools.local_http_fetch_max_timeout_seconds = 120; + config->tools.blossom_max_upload_bytes = 16 * 1024 * 1024; + config->tools.blossom_max_download_bytes = 16 * 1024 * 1024; config->tools.shell.enabled = 1; config->tools.shell.timeout_seconds = 30; config->tools.shell.max_output_bytes = 65536; diff --git a/src/config.h b/src/config.h index 1cdb630..90d9e38 100644 --- a/src/config.h +++ b/src/config.h @@ -54,6 +54,8 @@ typedef struct { int local_http_fetch_default_timeout_seconds; int local_http_fetch_max_timeout_seconds; shell_tools_config_t shell; + int blossom_max_upload_bytes; + int blossom_max_download_bytes; } tools_config_t; typedef struct { diff --git a/src/llm.c b/src/llm.c index f6cc804..f676b7b 100644 --- a/src/llm.c +++ b/src/llm.c @@ -2,7 +2,6 @@ #include "llm.h" -#include #include #include #include @@ -12,60 +11,11 @@ #include "cjson/cJSON.h" #include "debug.h" -typedef struct { - char* data; - size_t len; - size_t cap; -} response_buffer_t; +#include "../nostr_core_lib/nostr_core/nostr_http.h" static llm_config_t g_cfg; static int g_initialized = 0; -static size_t write_cb(void* contents, size_t size, size_t nmemb, void* userp) { - response_buffer_t* rb = (response_buffer_t*)userp; - size_t total = size * nmemb; - - if (rb->len + total + 1U > rb->cap) { - size_t new_cap = rb->cap == 0 ? 1024U : rb->cap * 2U; - while (new_cap < rb->len + total + 1U) { - new_cap *= 2U; - } - char* p = (char*)realloc(rb->data, new_cap); - if (!p) { - return 0; - } - rb->data = p; - rb->cap = new_cap; - } - - memcpy(rb->data + rb->len, contents, total); - rb->len += total; - rb->data[rb->len] = '\0'; - return total; -} - -static const char* detect_ca_bundle_path(void) { - const char* env = getenv("SSL_CERT_FILE"); - if (env && env[0] != '\0' && access(env, R_OK) == 0) { - return env; - } - - static const char* candidates[] = { - "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu - "/etc/ssl/cert.pem", // Alpine - "/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora - "/etc/ssl/ca-bundle.pem" // openSUSE - }; - - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { - if (access(candidates[i], R_OK) == 0) { - return candidates[i]; - } - } - - return NULL; -} - static int url_looks_like_websocket(const char* url) { if (!url) return 0; return (strncmp(url, "ws://", 5) == 0) || (strncmp(url, "wss://", 6) == 0); @@ -86,9 +36,7 @@ static int json_string_is_blank(const cJSON* item) { } static char* perform_http_request(const char* url, const char* body, int is_post) { - CURL* curl = curl_easy_init(); - if (!curl || !url) { - if (curl) curl_easy_cleanup(curl); + if (!url) { return NULL; } @@ -96,33 +44,30 @@ static char* perform_http_request(const char* url, const char* body, int is_post DEBUG_ERROR("[didactyl] llm config error: base_url must be HTTP(S), got WebSocket URL: %s", url); DEBUG_WARN("[didactyl] llm hint: set llm.base_url to an OpenAI-compatible HTTPS endpoint, e.g. https://api.example.com/v1"); - curl_easy_cleanup(curl); return NULL; } - - response_buffer_t rb = {0}; - struct curl_slist* headers = NULL; - headers = curl_slist_append(headers, "Content-Type: application/json"); - char auth_header[OW_MAX_KEY_LEN + 32]; snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", g_cfg.api_key); - headers = curl_slist_append(headers, auth_header); + const char* headers[] = { + "Content-Type: application/json", + auth_header, + NULL + }; - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L); + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = is_post ? "POST" : "GET"; + req.url = url; + req.headers = headers; + req.timeout_seconds = 60; + req.follow_redirects = 1; + req.max_redirects = 3; + req.user_agent = "didactyl/llm"; if (is_post) { - curl_easy_setopt(curl, CURLOPT_POST, 1L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}"); - } - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - - const char* ca_bundle = detect_ca_bundle_path(); - if (ca_bundle) { - curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle); + const char* payload = body ? body : "{}"; + req.body = (const unsigned char*)payload; + req.body_len = strlen(payload); } if (is_post) { @@ -137,44 +82,38 @@ static char* perform_http_request(const char* url, const char* body, int is_post DEBUG_INFO("[didactyl] llm request: method=GET url=%s", url); } - CURLcode res = curl_easy_perform(curl); - long status = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) { - DEBUG_ERROR("[didactyl] llm http request failed: curl=%s", curl_easy_strerror(res)); - if (rb.data && rb.len > 0) { - DEBUG_WARN("[didactyl] llm partial response: %.600s%s", - rb.data, - rb.len > 600 ? "..." : ""); - } - free(rb.data); + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); + if (rc != NOSTR_SUCCESS) { + DEBUG_ERROR("[didactyl] llm http request failed: transport error rc=%d", rc); return NULL; } - if (status < 200 || status >= 300) { - DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", status); - if (status == 101) { + if (resp.status_code < 200 || resp.status_code >= 300) { + DEBUG_ERROR("[didactyl] llm http request failed: status=%ld", resp.status_code); + if (resp.status_code == 101) { DEBUG_WARN("[didactyl] llm hint: received HTTP 101 (Switching Protocols), which usually means llm.base_url points to a WebSocket server instead of an HTTP LLM API"); } - if (rb.data && rb.len > 0) { + if (resp.body && resp.body_len > 0) { DEBUG_WARN("[didactyl] llm error response: %.1200s%s", - rb.data, - rb.len > 1200 ? "..." : ""); + resp.body, + resp.body_len > 1200 ? "..." : ""); } - free(rb.data); + nostr_http_response_free(&resp); return NULL; } - if (!rb.data) { + if (!resp.body) { DEBUG_ERROR("[didactyl] llm http request failed: empty response body"); + nostr_http_response_free(&resp); return NULL; } - return rb.data; + char* out = resp.body; + free(resp.content_type); + free(resp.headers_raw); + memset(&resp, 0, sizeof(resp)); + return out; } static char* perform_chat_request(const char* body) { @@ -315,7 +254,6 @@ int llm_init(const llm_config_t* config) { } memset(&g_cfg, 0, sizeof(g_cfg)); g_cfg = *config; - curl_global_init(CURL_GLOBAL_DEFAULT); g_initialized = 1; return 0; } @@ -512,7 +450,6 @@ void llm_cleanup(void) { if (!g_initialized) { return; } - curl_global_cleanup(); memset(&g_cfg, 0, sizeof(g_cfg)); g_initialized = 0; } diff --git a/src/main.c b/src/main.c index 4e02cb6..ec38c63 100644 --- a/src/main.c +++ b/src/main.c @@ -7,7 +7,7 @@ #include #include -#include "../../nostr_core_lib/nostr_core/nostr_core.h" +#include "../nostr_core_lib/nostr_core/nostr_core.h" #include "main.h" #include "agent.h" #include "config.h" @@ -1066,6 +1066,13 @@ int main(int argc, char** argv) { snprintf(cfg.api.bind_address, sizeof(cfg.api.bind_address), "%s", api_bind_override); } + { + const char* ca_bundle = nostr_http_detect_ca_bundle(); + if (ca_bundle && ca_bundle[0] != '\0') { + nostr_http_set_ca_bundle(ca_bundle); + } + } + if (dump_schemas || test_tool_name) { if (llm_init(&cfg.llm) != 0) { fprintf(stderr, "Failed to initialize llm client\n"); @@ -1102,6 +1109,7 @@ int main(int argc, char** argv) { if (cashu_wallet_init(&cfg) == 0) { wallet_initialized = 1; if (cfg.cashu_wallet.enabled) { + (void)nostr_handler_subscribe_wallet_events(); int wallet_rc = 0; if (cfg.cashu_wallet.auto_load) { wallet_rc = cashu_wallet_load_from_relays(); @@ -1420,10 +1428,18 @@ int main(int argc, char** argv) { DEBUG_INFO("[didactyl] startup phase: subscribe DMs end"); startup_step_ok(15, "Subscribe DMs", NULL); - startup_step_begin(16, "Initialize cashu wallet"); + startup_step_begin(16, "Subscribe wallet events"); + DEBUG_INFO("[didactyl] startup phase: subscribe wallet events begin"); + if (nostr_handler_subscribe_wallet_events() != 0) { + DEBUG_WARN("[didactyl] startup phase: subscribe wallet events failed (continuing)"); + } + DEBUG_INFO("[didactyl] startup phase: subscribe wallet events end"); + startup_step_ok(16, "Subscribe wallet events", NULL); + + startup_step_begin(17, "Initialize cashu wallet"); if (cashu_wallet_init(&cfg) != 0) { DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)"); - startup_step_fail(16, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors"); + startup_step_fail(17, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors"); } else { int wallet_rc = 0; if (cfg.cashu_wallet.auto_load) { @@ -1434,9 +1450,9 @@ int main(int argc, char** argv) { } if (wallet_rc != 0) { DEBUG_WARN("[didactyl] startup phase: cashu wallet load/create deferred to first tool call"); - startup_step_ok(16, "Initialize cashu wallet", "initialized; load/create deferred"); + startup_step_ok(17, "Initialize cashu wallet", "initialized; load/create deferred"); } else { - startup_step_ok(16, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized"); + startup_step_ok(17, "Initialize cashu wallet", cfg.cashu_wallet.auto_load ? "loaded or created" : "initialized"); } } @@ -1476,7 +1492,7 @@ int main(int argc, char** argv) { nostr_handler_refresh_relay_statuses(); - startup_step_ok(17, "READY", "agent online; entering main poll loop"); + startup_step_ok(18, "READY", "agent online; entering main poll loop"); DEBUG_INFO("[didactyl] entering main poll loop"); DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex); diff --git a/src/main.h b/src/main.h index 769294c..d745025 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 1 -#define DIDACTYL_VERSION_PATCH 19 -#define DIDACTYL_VERSION "v0.1.19" +#define DIDACTYL_VERSION_PATCH 20 +#define DIDACTYL_VERSION "v0.1.20" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 0f750ef..a0df91d 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -13,6 +13,7 @@ #include "debug.h" #include "trigger_manager.h" #include "nostr_block_list.h" +#include "cashu_wallet.h" #define NIP17_MAX_RELAYS 32 #define NIP17_MAX_GIFT_WRAPS 8 @@ -61,6 +62,7 @@ typedef enum { MANAGED_SUB_ADMIN_SKILLS, MANAGED_SUB_DM_KIND4, MANAGED_SUB_DM_KIND1059, + MANAGED_SUB_WALLET, MANAGED_SUB_COUNT } managed_subscription_id_t; @@ -404,6 +406,7 @@ static void load_startup_display_name(void); static void build_startup_kind1_content(char* out, size_t out_size, const char* fallback); static void register_trigger_from_self_skill_event(cJSON* event); static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_data); +static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data); static void dm_history_clear_locked(void); static void dm_history_remember(const char* peer_pubkey_hex, const char* content, int incoming, time_t created_at); static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url); @@ -591,6 +594,7 @@ static void managed_subscriptions_init_defaults(void) { g_managed_subs[MANAGED_SUB_ADMIN_SKILLS].name = "admin_skills"; g_managed_subs[MANAGED_SUB_DM_KIND4].name = "dms_kind4"; g_managed_subs[MANAGED_SUB_DM_KIND1059].name = "dms_kind1059"; + g_managed_subs[MANAGED_SUB_WALLET].name = "wallet_events"; g_managed_subs_initialized = 1; } @@ -2158,6 +2162,13 @@ static void on_self_skill_event(cJSON* event, const char* relay_url, void* user_ pthread_mutex_unlock(&g_self_skill_mutex); } +static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data) { + (void)event; + (void)relay_url; + (void)user_data; + (void)cashu_wallet_load_from_relays(); +} + int nostr_handler_init(didactyl_config_t* config) { if (!config) { return -1; @@ -2634,6 +2645,57 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { return 0; } +int nostr_handler_subscribe_wallet_events(void) { + if (!g_cfg || !g_pool) { + return -1; + } + + cJSON* filter = cJSON_CreateObject(); + cJSON* kinds = cJSON_CreateArray(); + cJSON* authors = cJSON_CreateArray(); + if (!filter || !kinds || !authors) { + cJSON_Delete(filter); + cJSON_Delete(kinds); + cJSON_Delete(authors); + return -1; + } + + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_WALLET_KIND)); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(NOSTR_NIP60_TOKEN_KIND)); + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(5)); + cJSON_AddItemToObject(filter, "kinds", kinds); + cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + cJSON_AddNumberToObject(filter, "since", (double)g_start_time); + cJSON_AddNumberToObject(filter, "limit", 500); + + int rc = 0; + pthread_mutex_lock(&g_subscription_mutex); + managed_subscriptions_init_defaults(); + if (managed_subscription_register_locked(MANAGED_SUB_WALLET, + filter, + on_wallet_event, + NULL, + NULL, + 0, + 0, + NOSTR_POOL_EOSE_FULL_SET, + 30, + 120, + 1) != 0) { + rc = -1; + } + pthread_mutex_unlock(&g_subscription_mutex); + cJSON_Delete(filter); + + if (rc != 0) { + return -1; + } + + DEBUG_INFO("[didactyl] wallet event subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex); + return 0; +} + void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data) { g_self_skill_eose_cb = callback; g_self_skill_eose_user_data = user_data; diff --git a/src/nostr_handler.h b/src/nostr_handler.h index 5d89562..976ed3e 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -40,6 +40,7 @@ int nostr_handler_subscribe_self_skills(void); char* nostr_handler_get_self_events_by_kind_json(int kind); void nostr_handler_set_self_skill_eose_callback(nostr_self_skill_eose_cb_t callback, void* user_data); int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data); +int nostr_handler_subscribe_wallet_events(void); int nostr_handler_send_dm(const char* recipient_pubkey_hex, const char* message); int nostr_handler_send_dm_auto(const char* recipient_pubkey_hex, const char* message); int nostr_handler_publish_kind_event(int kind, const char* content, cJSON* tags, nostr_publish_result_t* out_result); diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 294bbc8..856c64d 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -278,6 +278,8 @@ static void config_set_defaults(didactyl_config_t* cfg) { cfg->tools.stall_repeat_threshold = 3; cfg->tools.local_http_fetch_default_timeout_seconds = 20; cfg->tools.local_http_fetch_max_timeout_seconds = 120; + cfg->tools.blossom_max_upload_bytes = 16 * 1024 * 1024; + cfg->tools.blossom_max_download_bytes = 16 * 1024 * 1024; cfg->tools.shell.enabled = 1; cfg->tools.shell.timeout_seconds = 30; cfg->tools.shell.max_output_bytes = 65536; diff --git a/src/tools/tool_blossom.c b/src/tools/tool_blossom.c new file mode 100644 index 0000000..8c1a6a3 --- /dev/null +++ b/src/tools/tool_blossom.c @@ -0,0 +1,304 @@ +#define _POSIX_C_SOURCE 200809L + +#include "tools_internal.h" + +#include +#include +#include +#include + +#include +#include + +#include "cjson/cJSON.h" +#include "../../nostr_core_lib/nostr_core/blossom_client.h" + +static char* json_error_local(const char* msg) { + cJSON* root = cJSON_CreateObject(); + if (!root) return NULL; + cJSON_AddBoolToObject(root, "success", 0); + cJSON_AddStringToObject(root, "error", msg ? msg : "unknown error"); + char* out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + return out; +} + +static cJSON* parse_args_local(const char* args_json) { + const char* raw = args_json ? args_json : "{}"; + cJSON* args = cJSON_Parse(raw); + if (!args || !cJSON_IsObject(args)) { + cJSON_Delete(args); + return NULL; + } + return args; +} + +static int is_safe_relative_path_local(const char* path) { + if (!path || path[0] == '\0') return 0; + if (path[0] == '/') return 0; + if (strstr(path, "..") != NULL) return 0; + if (strchr(path, '\\') != NULL) return 0; + return 1; +} + +static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) { + if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1; + if (!is_safe_relative_path_local(rel_path)) return -1; + const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0' ? ctx->cfg->tools.shell.working_directory : "."; + int n = (strcmp(cwd, ".") == 0) ? snprintf(out, out_size, "%s", rel_path) + : snprintf(out, out_size, "%s/%s", cwd, rel_path); + if (n < 0 || (size_t)n >= out_size) return -1; + return 0; +} + +static int is_https_server_local(const char* server) { + return server && strncmp(server, "https://", 8) == 0; +} + +static int file_exists_local(const char* path) { + if (!path || path[0] == '\0') return 0; + return access(path, F_OK) == 0; +} + +static int file_size_local(const char* path, size_t* out_size) { + if (!path || !out_size) return -1; + struct stat st; + if (stat(path, &st) != 0) return -1; + if (st.st_size < 0) return -1; + *out_size = (size_t)st.st_size; + return 0; +} + +static void descriptor_to_json(cJSON* obj, const blossom_blob_descriptor_t* d) { + if (!obj || !d) return; + cJSON_AddStringToObject(obj, "sha256", d->sha256); + cJSON_AddStringToObject(obj, "url", d->url); + cJSON_AddNumberToObject(obj, "size", d->size); + cJSON_AddStringToObject(obj, "content_type", d->content_type); + cJSON_AddNumberToObject(obj, "created", d->created); +} + +char* execute_blossom_upload(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server"); + cJSON* file_path = cJSON_GetObjectItemCaseSensitive(args, "file_path"); + cJSON* content_type = cJSON_GetObjectItemCaseSensitive(args, "content_type"); + + if (!server || !cJSON_IsString(server) || !server->valuestring || server->valuestring[0] == '\0' || + !file_path || !cJSON_IsString(file_path) || !file_path->valuestring || file_path->valuestring[0] == '\0') { + cJSON_Delete(args); + return json_error_local("blossom_upload requires server and file_path"); + } + + if (!is_https_server_local(server->valuestring)) { + cJSON_Delete(args); + return json_error_local("blossom_upload requires an https:// server URL"); + } + + char full_path[PATH_MAX]; + if (build_tool_path_local(ctx, file_path->valuestring, full_path, sizeof(full_path)) != 0) { + cJSON_Delete(args); + return json_error_local("blossom_upload file_path is not allowed"); + } + + size_t input_size = 0; + if (file_size_local(full_path, &input_size) != 0) { + cJSON_Delete(args); + return json_error_local("blossom_upload could not stat file_path"); + } + + int upload_max = ctx->cfg->tools.blossom_max_upload_bytes > 0 ? ctx->cfg->tools.blossom_max_upload_bytes : (16 * 1024 * 1024); + if (input_size > (size_t)upload_max) { + cJSON_Delete(args); + return json_error_local("blossom_upload file exceeds configured max upload size"); + } + + blossom_blob_descriptor_t d; + int rc = blossom_upload_file(server->valuestring, + full_path, + (content_type && cJSON_IsString(content_type) && content_type->valuestring) ? content_type->valuestring : NULL, + ctx->cfg->keys.private_key, + 30, + &d); + cJSON_Delete(args); + if (rc != NOSTR_SUCCESS) return json_error_local("blossom_upload failed"); + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "server", server->valuestring); + descriptor_to_json(out, &d); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* execute_blossom_download(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server"); + cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256"); + cJSON* output_path = cJSON_GetObjectItemCaseSensitive(args, "output_path"); + + if (!server || !cJSON_IsString(server) || !server->valuestring || server->valuestring[0] == '\0' || + !sha || !cJSON_IsString(sha) || !sha->valuestring || + !output_path || !cJSON_IsString(output_path) || !output_path->valuestring) { + cJSON_Delete(args); + return json_error_local("blossom_download requires server, sha256, and output_path"); + } + + if (!is_https_server_local(server->valuestring)) { + cJSON_Delete(args); + return json_error_local("blossom_download requires an https:// server URL"); + } + + char full_path[PATH_MAX]; + if (build_tool_path_local(ctx, output_path->valuestring, full_path, sizeof(full_path)) != 0) { + cJSON_Delete(args); + return json_error_local("blossom_download output_path is not allowed"); + } + + cJSON* overwrite = cJSON_GetObjectItemCaseSensitive(args, "overwrite"); + int allow_overwrite = (overwrite && cJSON_IsBool(overwrite) && cJSON_IsTrue(overwrite)) ? 1 : 0; + if (!allow_overwrite && file_exists_local(full_path)) { + cJSON_Delete(args); + return json_error_local("blossom_download output_path exists (set overwrite=true to replace)"); + } + + int download_max = ctx->cfg->tools.blossom_max_download_bytes > 0 ? ctx->cfg->tools.blossom_max_download_bytes : (16 * 1024 * 1024); + blossom_blob_descriptor_t d; + int rc = blossom_download_to_file(server->valuestring, sha->valuestring, full_path, 30, (size_t)download_max, &d); + cJSON_Delete(args); + if (rc != NOSTR_SUCCESS) return json_error_local("blossom_download failed"); + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "output_path", full_path); + descriptor_to_json(out, &d); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* execute_blossom_head(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server"); + cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256"); + if (!server || !cJSON_IsString(server) || !server->valuestring || !sha || !cJSON_IsString(sha) || !sha->valuestring) { + cJSON_Delete(args); + return json_error_local("blossom_head requires server and sha256"); + } + + if (!is_https_server_local(server->valuestring)) { + cJSON_Delete(args); + return json_error_local("blossom_head requires an https:// server URL"); + } + + blossom_blob_descriptor_t d; + int rc = blossom_head(server->valuestring, sha->valuestring, 15, &d); + cJSON_Delete(args); + if (rc != NOSTR_SUCCESS) return json_error_local("blossom_head failed"); + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddBoolToObject(out, "exists", 1); + descriptor_to_json(out, &d); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* execute_blossom_delete(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server"); + cJSON* sha = cJSON_GetObjectItemCaseSensitive(args, "sha256"); + if (!server || !cJSON_IsString(server) || !server->valuestring || !sha || !cJSON_IsString(sha) || !sha->valuestring) { + cJSON_Delete(args); + return json_error_local("blossom_delete requires server and sha256"); + } + + if (!is_https_server_local(server->valuestring)) { + cJSON_Delete(args); + return json_error_local("blossom_delete requires an https:// server URL"); + } + + int rc = blossom_delete(server->valuestring, sha->valuestring, ctx->cfg->keys.private_key, 15); + cJSON_Delete(args); + if (rc != NOSTR_SUCCESS) return json_error_local("blossom_delete failed"); + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddBoolToObject(out, "deleted", 1); + cJSON_AddStringToObject(out, "sha256", sha->valuestring); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +char* execute_blossom_list(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); + cJSON* args = parse_args_local(args_json); + if (!args) return json_error_local("invalid arguments JSON"); + + cJSON* server = cJSON_GetObjectItemCaseSensitive(args, "server"); + cJSON* pubkey = cJSON_GetObjectItemCaseSensitive(args, "pubkey"); + if (!server || !cJSON_IsString(server) || !server->valuestring) { + cJSON_Delete(args); + return json_error_local("blossom_list requires server"); + } + + if (!is_https_server_local(server->valuestring)) { + cJSON_Delete(args); + return json_error_local("blossom_list requires an https:// server URL"); + } + + const char* pk = (pubkey && cJSON_IsString(pubkey) && pubkey->valuestring && pubkey->valuestring[0] != '\0') + ? pubkey->valuestring + : ctx->cfg->keys.public_key_hex; + + blossom_blob_descriptor_t* items = NULL; + int count = 0; + int rc = blossom_list(server->valuestring, pk, 20, &items, &count); + cJSON_Delete(args); + if (rc != NOSTR_SUCCESS) return json_error_local("blossom_list failed"); + + cJSON* out = cJSON_CreateObject(); + cJSON* arr = cJSON_CreateArray(); + if (!out || !arr) { + free(items); + cJSON_Delete(out); + cJSON_Delete(arr); + return NULL; + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "pubkey", pk); + cJSON_AddNumberToObject(out, "count", count); + + for (int i = 0; i < count; i++) { + cJSON* it = cJSON_CreateObject(); + if (!it) continue; + descriptor_to_json(it, &items[i]); + cJSON_AddItemToArray(arr, it); + } + free(items); + + cJSON_AddItemToObject(out, "blobs", arr); + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} diff --git a/src/tools/tool_local.c b/src/tools/tool_local.c index ba38c57..3a74ca4 100644 --- a/src/tools/tool_local.c +++ b/src/tools/tool_local.c @@ -2,7 +2,6 @@ #include "tools_internal.h" -#include #include #include #include @@ -13,13 +12,7 @@ #include "cjson/cJSON.h" -typedef struct { - char* data; - size_t len; - size_t cap; - size_t max_bytes; - int truncated; -} local_http_fetch_buffer_t; +#include "../../nostr_core_lib/nostr_core/nostr_http.h" static char* json_error_local(const char* msg) { cJSON* root = cJSON_CreateObject(); @@ -102,63 +95,6 @@ static char* shell_quote_single_local(const char* in) { return out; } -static size_t local_http_fetch_write_cb_local(void* contents, size_t size, size_t nmemb, void* userp) { - local_http_fetch_buffer_t* rb = (local_http_fetch_buffer_t*)userp; - size_t total = size * nmemb; - if (!rb || total == 0) return total; - - if (rb->len >= rb->max_bytes) { - rb->truncated = 1; - return total; - } - - size_t allowed = rb->max_bytes - rb->len; - size_t to_copy = total <= allowed ? total : allowed; - - if (rb->len + to_copy + 1U > rb->cap) { - size_t new_cap = rb->cap == 0 ? 1024U : rb->cap; - while (new_cap < rb->len + to_copy + 1U) { - new_cap *= 2U; - } - char* bigger = (char*)realloc(rb->data, new_cap); - if (!bigger) return 0; - rb->data = bigger; - rb->cap = new_cap; - } - - memcpy(rb->data + rb->len, contents, to_copy); - rb->len += to_copy; - rb->data[rb->len] = '\0'; - - if (to_copy < total) { - rb->truncated = 1; - } - - return total; -} - -static const char* detect_ca_bundle_path_for_tools_local(void) { - const char* env = getenv("SSL_CERT_FILE"); - if (env && env[0] != '\0' && access(env, R_OK) == 0) { - return env; - } - - static const char* candidates[] = { - "/etc/ssl/certs/ca-certificates.crt", - "/etc/ssl/cert.pem", - "/etc/pki/tls/certs/ca-bundle.crt", - "/etc/ssl/ca-bundle.pem" - }; - - for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { - if (access(candidates[i], R_OK) == 0) { - return candidates[i]; - } - } - - return NULL; -} - char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) { if (!ctx || !ctx->cfg) return json_error_local("tool context unavailable"); @@ -212,104 +148,87 @@ char* execute_local_http_fetch(tools_context_t* ctx, const char* args_json) { int max_bytes = (maxb && cJSON_IsNumber(maxb)) ? (int)maxb->valuedouble : hard_max; if (max_bytes <= 0 || max_bytes > hard_max) max_bytes = hard_max; - CURL* curl = curl_easy_init(); - if (!curl) { - cJSON_Delete(args); - return json_error_local("local_http_fetch failed to initialize curl"); + const char* default_header = "Accept: */*"; + int hdr_count = 1; + if (headers && cJSON_IsArray(headers)) { + hdr_count += cJSON_GetArraySize(headers); } - local_http_fetch_buffer_t rb; - memset(&rb, 0, sizeof(rb)); - rb.max_bytes = (size_t)max_bytes; - - struct curl_slist* req_headers = NULL; - req_headers = curl_slist_append(req_headers, "Accept: */*"); - + const char** req_headers = (const char**)calloc((size_t)hdr_count + 1U, sizeof(const char*)); + if (!req_headers) { + cJSON_Delete(args); + return json_error_local("local_http_fetch allocation failure"); + } + int hi = 0; + req_headers[hi++] = default_header; if (headers && cJSON_IsArray(headers)) { int n = cJSON_GetArraySize(headers); for (int i = 0; i < n; i++) { cJSON* h = cJSON_GetArrayItem(headers, i); if (h && cJSON_IsString(h) && h->valuestring && h->valuestring[0] != '\0') { - req_headers = curl_slist_append(req_headers, h->valuestring); + req_headers[hi++] = h->valuestring; } } } + req_headers[hi] = NULL; - curl_easy_setopt(curl, CURLOPT_URL, url->valuestring); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, (long)timeout_seconds); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, local_http_fetch_write_cb_local); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, req_headers); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "didactyl/local_http_fetch"); - - const char* ca_bundle = detect_ca_bundle_path_for_tools_local(); - if (ca_bundle) { - curl_easy_setopt(curl, CURLOPT_CAINFO, ca_bundle); + nostr_http_request_t req; + memset(&req, 0, sizeof(req)); + req.method = method_str; + req.url = url->valuestring; + req.headers = req_headers; + req.timeout_seconds = timeout_seconds; + req.max_response_bytes = (size_t)max_bytes; + req.follow_redirects = 1; + req.max_redirects = 3; + req.user_agent = "didactyl/local_http_fetch"; + if (body && cJSON_IsString(body) && body->valuestring) { + req.body = (const unsigned char*)body->valuestring; + req.body_len = strlen(body->valuestring); } - if (strcasecmp(method_str, "GET") == 0) { - curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L); - } else if (strcasecmp(method_str, "POST") == 0) { - curl_easy_setopt(curl, CURLOPT_POST, 1L); - if (body && cJSON_IsString(body) && body->valuestring) { - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring)); - } - } else { - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method_str); - if (body && cJSON_IsString(body) && body->valuestring) { - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body->valuestring); - curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body->valuestring)); - } - } - - CURLcode res = curl_easy_perform(curl); - long status_code = 0; - char* content_type = NULL; - char* content_type_copy = NULL; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status_code); - curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &content_type); - if (content_type && content_type[0] != '\0') { - content_type_copy = strdup(content_type); - } - - curl_slist_free_all(req_headers); - curl_easy_cleanup(curl); + nostr_http_response_t resp; + int rc = nostr_http_request(&req, &resp); cJSON* out = cJSON_CreateObject(); if (!out) { - free(rb.data); + free(req_headers); + cJSON_Delete(args); + if (rc == NOSTR_SUCCESS) { + nostr_http_response_free(&resp); + } return NULL; } - int http_ok = (status_code >= 200 && status_code < 300) ? 1 : 0; - int success = (res == CURLE_OK && http_ok) ? 1 : 0; + int http_ok = (rc == NOSTR_SUCCESS && resp.status_code >= 200 && resp.status_code < 300) ? 1 : 0; + int success = http_ok ? 1 : 0; cJSON_AddBoolToObject(out, "success", success); cJSON_AddStringToObject(out, "url", url->valuestring); cJSON_AddStringToObject(out, "method", method_str); - cJSON_AddNumberToObject(out, "status_code", status_code); + cJSON_AddNumberToObject(out, "status_code", rc == NOSTR_SUCCESS ? resp.status_code : 0); cJSON_AddBoolToObject(out, "http_ok", http_ok); - cJSON_AddBoolToObject(out, "truncated", rb.truncated ? 1 : 0); - cJSON_AddNumberToObject(out, "bytes_received", (double)rb.len); + cJSON_AddBoolToObject(out, "truncated", (rc == NOSTR_SUCCESS && resp.truncated) ? 1 : 0); + cJSON_AddNumberToObject(out, "bytes_received", rc == NOSTR_SUCCESS ? (double)resp.body_len : 0.0); - if (content_type_copy && content_type_copy[0] != '\0') { - cJSON_AddStringToObject(out, "content_type", content_type_copy); + if (rc == NOSTR_SUCCESS && resp.content_type && resp.content_type[0] != '\0') { + cJSON_AddStringToObject(out, "content_type", resp.content_type); } - if (res != CURLE_OK) { - cJSON_AddStringToObject(out, "curl_error", curl_easy_strerror(res)); + if (rc != NOSTR_SUCCESS) { + cJSON_AddStringToObject(out, "curl_error", "request failed"); } - cJSON_AddStringToObject(out, "body", rb.data ? rb.data : ""); + cJSON_AddStringToObject(out, "body", (rc == NOSTR_SUCCESS && resp.body) ? resp.body : ""); - free(rb.data); + if (rc == NOSTR_SUCCESS) { + nostr_http_response_free(&resp); + } char* json = cJSON_PrintUnformatted(out); cJSON_Delete(out); cJSON_Delete(args); - free(content_type_copy); + free(req_headers); return json; } diff --git a/src/tools/tools_dispatch.c b/src/tools/tools_dispatch.c index 56162ec..b4bfa51 100644 --- a/src/tools/tools_dispatch.c +++ b/src/tools/tools_dispatch.c @@ -274,6 +274,21 @@ char* tools_execute_legacy(tools_context_t* ctx, const char* tool_name, const ch if (strcmp(tool_name, "cashu_wallet_mints_set") == 0) { return execute_cashu_wallet_mints_set(ctx, args_json); } + if (strcmp(tool_name, "blossom_upload") == 0) { + return execute_blossom_upload(ctx, args_json); + } + if (strcmp(tool_name, "blossom_download") == 0) { + return execute_blossom_download(ctx, args_json); + } + if (strcmp(tool_name, "blossom_head") == 0) { + return execute_blossom_head(ctx, args_json); + } + if (strcmp(tool_name, "blossom_delete") == 0) { + return execute_blossom_delete(ctx, args_json); + } + if (strcmp(tool_name, "blossom_list") == 0) { + return execute_blossom_list(ctx, args_json); + } return json_error("unknown tool"); } diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index 39dff27..d15ed6a 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -84,6 +84,12 @@ char* execute_cashu_wallet_send_token(tools_context_t* ctx, const char* args_jso char* execute_cashu_wallet_mints_get(tools_context_t* ctx, const char* args_json); char* execute_cashu_wallet_mints_set(tools_context_t* ctx, const char* args_json); +char* execute_blossom_upload(tools_context_t* ctx, const char* args_json); +char* execute_blossom_download(tools_context_t* ctx, const char* args_json); +char* execute_blossom_head(tools_context_t* ctx, const char* args_json); +char* execute_blossom_delete(tools_context_t* ctx, const char* args_json); +char* execute_blossom_list(tools_context_t* ctx, const char* args_json); + int memory_init(tools_context_t* ctx); void memory_cleanup(void); diff --git a/src/tools/tools_schema.c b/src/tools/tools_schema.c index e4e7bda..317275e 100644 --- a/src/tools/tools_schema.c +++ b/src/tools/tools_schema.c @@ -1908,6 +1908,104 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddItemToObject(t62, "function", t62_fn); cJSON_AddItemToArray(tools, t62); + cJSON* t63 = cJSON_CreateObject(); + cJSON* t63_fn = cJSON_CreateObject(); + cJSON* t63_params = cJSON_CreateObject(); + cJSON* t63_props = cJSON_CreateObject(); + cJSON* t63_required = cJSON_CreateArray(); + cJSON_AddStringToObject(t63, "type", "function"); + cJSON_AddStringToObject(t63_fn, "name", "blossom_upload"); + cJSON_AddStringToObject(t63_fn, "description", "Upload a local file to a Blossom server"); + cJSON_AddStringToObject(t63_params, "type", "object"); + cJSON_AddItemToObject(t63_params, "properties", t63_props); + cJSON_AddItemToObject(t63_params, "required", t63_required); + cJSON* p63_server = cJSON_CreateObject(); cJSON_AddStringToObject(p63_server, "type", "string"); cJSON_AddItemToObject(t63_props, "server", p63_server); + cJSON* p63_file = cJSON_CreateObject(); cJSON_AddStringToObject(p63_file, "type", "string"); cJSON_AddItemToObject(t63_props, "file_path", p63_file); + cJSON* p63_ct = cJSON_CreateObject(); cJSON_AddStringToObject(p63_ct, "type", "string"); cJSON_AddItemToObject(t63_props, "content_type", p63_ct); + cJSON_AddItemToArray(t63_required, cJSON_CreateString("server")); + cJSON_AddItemToArray(t63_required, cJSON_CreateString("file_path")); + cJSON_AddItemToObject(t63_fn, "parameters", t63_params); + cJSON_AddItemToObject(t63, "function", t63_fn); + cJSON_AddItemToArray(tools, t63); + + cJSON* t64 = cJSON_CreateObject(); + cJSON* t64_fn = cJSON_CreateObject(); + cJSON* t64_params = cJSON_CreateObject(); + cJSON* t64_props = cJSON_CreateObject(); + cJSON* t64_required = cJSON_CreateArray(); + cJSON_AddStringToObject(t64, "type", "function"); + cJSON_AddStringToObject(t64_fn, "name", "blossom_download"); + cJSON_AddStringToObject(t64_fn, "description", "Download a blob from Blossom to a local file"); + cJSON_AddStringToObject(t64_params, "type", "object"); + cJSON_AddItemToObject(t64_params, "properties", t64_props); + cJSON_AddItemToObject(t64_params, "required", t64_required); + cJSON* p64_server = cJSON_CreateObject(); cJSON_AddStringToObject(p64_server, "type", "string"); cJSON_AddItemToObject(t64_props, "server", p64_server); + cJSON* p64_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p64_sha, "type", "string"); cJSON_AddItemToObject(t64_props, "sha256", p64_sha); + cJSON* p64_out = cJSON_CreateObject(); cJSON_AddStringToObject(p64_out, "type", "string"); cJSON_AddItemToObject(t64_props, "output_path", p64_out); + cJSON* p64_overwrite = cJSON_CreateObject(); cJSON_AddStringToObject(p64_overwrite, "type", "boolean"); cJSON_AddItemToObject(t64_props, "overwrite", p64_overwrite); + cJSON_AddItemToArray(t64_required, cJSON_CreateString("server")); + cJSON_AddItemToArray(t64_required, cJSON_CreateString("sha256")); + cJSON_AddItemToArray(t64_required, cJSON_CreateString("output_path")); + cJSON_AddItemToObject(t64_fn, "parameters", t64_params); + cJSON_AddItemToObject(t64, "function", t64_fn); + cJSON_AddItemToArray(tools, t64); + + cJSON* t65 = cJSON_CreateObject(); + cJSON* t65_fn = cJSON_CreateObject(); + cJSON* t65_params = cJSON_CreateObject(); + cJSON* t65_props = cJSON_CreateObject(); + cJSON* t65_required = cJSON_CreateArray(); + cJSON_AddStringToObject(t65, "type", "function"); + cJSON_AddStringToObject(t65_fn, "name", "blossom_head"); + cJSON_AddStringToObject(t65_fn, "description", "Fetch blob metadata from Blossom"); + cJSON_AddStringToObject(t65_params, "type", "object"); + cJSON_AddItemToObject(t65_params, "properties", t65_props); + cJSON_AddItemToObject(t65_params, "required", t65_required); + cJSON* p65_server = cJSON_CreateObject(); cJSON_AddStringToObject(p65_server, "type", "string"); cJSON_AddItemToObject(t65_props, "server", p65_server); + cJSON* p65_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p65_sha, "type", "string"); cJSON_AddItemToObject(t65_props, "sha256", p65_sha); + cJSON_AddItemToArray(t65_required, cJSON_CreateString("server")); + cJSON_AddItemToArray(t65_required, cJSON_CreateString("sha256")); + cJSON_AddItemToObject(t65_fn, "parameters", t65_params); + cJSON_AddItemToObject(t65, "function", t65_fn); + cJSON_AddItemToArray(tools, t65); + + cJSON* t66 = cJSON_CreateObject(); + cJSON* t66_fn = cJSON_CreateObject(); + cJSON* t66_params = cJSON_CreateObject(); + cJSON* t66_props = cJSON_CreateObject(); + cJSON* t66_required = cJSON_CreateArray(); + cJSON_AddStringToObject(t66, "type", "function"); + cJSON_AddStringToObject(t66_fn, "name", "blossom_delete"); + cJSON_AddStringToObject(t66_fn, "description", "Delete a blob from Blossom"); + cJSON_AddStringToObject(t66_params, "type", "object"); + cJSON_AddItemToObject(t66_params, "properties", t66_props); + cJSON_AddItemToObject(t66_params, "required", t66_required); + cJSON* p66_server = cJSON_CreateObject(); cJSON_AddStringToObject(p66_server, "type", "string"); cJSON_AddItemToObject(t66_props, "server", p66_server); + cJSON* p66_sha = cJSON_CreateObject(); cJSON_AddStringToObject(p66_sha, "type", "string"); cJSON_AddItemToObject(t66_props, "sha256", p66_sha); + cJSON_AddItemToArray(t66_required, cJSON_CreateString("server")); + cJSON_AddItemToArray(t66_required, cJSON_CreateString("sha256")); + cJSON_AddItemToObject(t66_fn, "parameters", t66_params); + cJSON_AddItemToObject(t66, "function", t66_fn); + cJSON_AddItemToArray(tools, t66); + + cJSON* t67 = cJSON_CreateObject(); + cJSON* t67_fn = cJSON_CreateObject(); + cJSON* t67_params = cJSON_CreateObject(); + cJSON* t67_props = cJSON_CreateObject(); + cJSON* t67_required = cJSON_CreateArray(); + cJSON_AddStringToObject(t67, "type", "function"); + cJSON_AddStringToObject(t67_fn, "name", "blossom_list"); + cJSON_AddStringToObject(t67_fn, "description", "List blobs for a pubkey on Blossom"); + cJSON_AddStringToObject(t67_params, "type", "object"); + cJSON_AddItemToObject(t67_params, "properties", t67_props); + cJSON_AddItemToObject(t67_params, "required", t67_required); + cJSON* p67_server = cJSON_CreateObject(); cJSON_AddStringToObject(p67_server, "type", "string"); cJSON_AddItemToObject(t67_props, "server", p67_server); + cJSON* p67_pubkey = cJSON_CreateObject(); cJSON_AddStringToObject(p67_pubkey, "type", "string"); cJSON_AddItemToObject(t67_props, "pubkey", p67_pubkey); + cJSON_AddItemToArray(t67_required, cJSON_CreateString("server")); + cJSON_AddItemToObject(t67_fn, "parameters", t67_params); + cJSON_AddItemToObject(t67, "function", t67_fn); + cJSON_AddItemToArray(tools, t67); + char* out = cJSON_PrintUnformatted(tools); cJSON_Delete(tools); return out; diff --git a/tests/blossom_tool_validation_test b/tests/blossom_tool_validation_test new file mode 100755 index 0000000..dd1a4cc Binary files /dev/null and b/tests/blossom_tool_validation_test differ diff --git a/tests/blossom_tool_validation_test.c b/tests/blossom_tool_validation_test.c new file mode 100644 index 0000000..edae540 --- /dev/null +++ b/tests/blossom_tool_validation_test.c @@ -0,0 +1,79 @@ +/* + * Didactyl Blossom tool argument validation tests. + */ + +#include +#include +#include + +#include "../src/config.h" +#include "../src/tools/tools.h" +#include "../src/tools/tools_internal.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (cond) { tests_passed++; printf("✅ %s\n", msg); } \ + else { printf("❌ %s\n", msg); } \ +} while (0) + +static int json_contains_error(const char* json, const char* needle) { + return json && strstr(json, "\"success\":false") && strstr(json, needle); +} + +static int write_file_text(const char* path, const char* text) { + FILE* fp = fopen(path, "wb"); + if (!fp) return -1; + size_t n = fwrite(text, 1, strlen(text), fp); + fclose(fp); + return (n == strlen(text)) ? 0 : -1; +} + +int main(void) { + printf("Didactyl Blossom tool validation tests\n"); + printf("======================================\n"); + + didactyl_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + snprintf(cfg.tools.shell.working_directory, sizeof(cfg.tools.shell.working_directory), "%s", "."); + cfg.tools.blossom_max_upload_bytes = 16; + cfg.tools.blossom_max_download_bytes = 1024; + + tools_context_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.cfg = &cfg; + + char* out = execute_blossom_upload(&ctx, "{\"server\":\"http://example.com\",\"file_path\":\"README.md\"}"); + TEST_ASSERT(json_contains_error(out, "https://"), "blossom_upload rejects non-https server"); + free(out); + + const char* big_file = "tests/.tmp_blossom_big.txt"; + if (write_file_text(big_file, "this payload is intentionally larger than 16 bytes") != 0) { + printf("❌ setup failed to create temp file\n"); + return 1; + } + + out = execute_blossom_upload(&ctx, "{\"server\":\"https://example.com\",\"file_path\":\"tests/.tmp_blossom_big.txt\"}"); + TEST_ASSERT(json_contains_error(out, "max upload size"), "blossom_upload enforces configured upload max"); + free(out); + + const char* existing = "tests/.tmp_blossom_existing.txt"; + if (write_file_text(existing, "already here") != 0) { + printf("❌ setup failed to create existing output file\n"); + remove(big_file); + return 1; + } + + out = execute_blossom_download(&ctx, + "{\"server\":\"https://example.com\",\"sha256\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"output_path\":\"tests/.tmp_blossom_existing.txt\"}"); + TEST_ASSERT(json_contains_error(out, "output_path exists"), "blossom_download requires overwrite=true when file exists"); + free(out); + + remove(big_file); + remove(existing); + + printf("\nSummary: %d/%d passed\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +}