diff --git a/Makefile b/Makefile index 763b23a..9656875 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ CC = gcc -CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN +CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 -D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN -DNOSTR_ENABLE_NSIGNER_CLIENT=1 SRC_DIR = src TARGET = didactyl @@ -44,7 +44,8 @@ SRCS = \ $(SRC_DIR)/nostr_block_list.c \ $(SRC_DIR)/json_to_markdown.c \ $(SRC_DIR)/context_roles.c \ - $(SRC_DIR)/context_format.c + $(SRC_DIR)/context_format.c \ + $(SRC_DIR)/signer_health.c INCLUDES = \ -I$(SRC_DIR) \ diff --git a/README.md b/README.md index dba501f..ceb0c25 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,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.2.50 +## Current Status — v0.2.51 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.2.50 — Fix startup step-14 false-negative by adding sync self-skill retry validation and post-auth query REQ resend +> Last release update: v0.2.51 — Implement n_signer integration through Phase 4: signer config/CLI/wizard, signer-backed signing/encryption/auth/cashu/blossom paths, nsigner_unix e2e validation, and signer health observability - 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/didactyl.pid b/didactyl.pid new file mode 100644 index 0000000..6bbefa1 --- /dev/null +++ b/didactyl.pid @@ -0,0 +1 @@ +131350 diff --git a/didactyl_nsigner.log b/didactyl_nsigner.log new file mode 100644 index 0000000..8f04bd0 --- /dev/null +++ b/didactyl_nsigner.log @@ -0,0 +1,70 @@ +Didactyl - sovereign Nostr agent + +Usage: + ./didactyl [options] + +Options: + -h, --help + Show this help text and exit. + -v, --version + Show version and exit. + --config + Path to config/genesis JSONC file (default: ./genesis.jsonc). + If file is missing, startup falls back to minimal nsec-only mode. + --nsec + Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC. + --admin + Administrator pubkey override; accepts npub1... or 64-char hex. + --api-port + Enable HTTP API and bind to this port (1-65535). + --api-bind + Enable HTTP API and bind address (example: 127.0.0.1). + --debug <0-5> + Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace). + --signer + Signer provider: local | nsigner_unix | nsigner_tcp. + Overrides signer.mode in config and DIDACTYL_SIGNER. + In nsigner_* modes --nsec/DIDACTYL_NSEC are not required. + --signer-socket + Abstract socket name for nsigner_unix (without leading @). + --signer-role + n_signer role selector (default: main). + --signer-timeout + Per-call signer timeout in milliseconds (default: 15000). + --signer-tcp + Shorthand for --signer nsigner_tcp with host/port parsed. + --dump-schemas + Print tool schemas JSON and exit. + --test-tool + Execute one tool call locally and print JSON result, then exit. + +Environment: + DIDACTYL_NSEC + Fallback private key when --nsec is not provided (local mode only). + DIDACTYL_SIGNER + Fallback signer mode when --signer is not provided. + +Examples: + 1) Genesis-based startup + ./didactyl --config genesis.jsonc + + 2) nsec-only startup (no config file) + ./didactyl --nsec nsec1... + + 3) nsec via environment variable + DIDACTYL_NSEC=nsec1... ./didactyl + + 4) Override API bind/port + ./didactyl --config genesis.jsonc --api-bind 127.0.0.1 --api-port 8484 + + 5) Sign with a running n_signer (separate the nsec from the agent) + ./didactyl --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1... + + 6) n_signer via TCP + ./didactyl --signer-tcp 127.0.0.1:7777 --admin npub1... + + 7) Dump tool schemas + ./didactyl --dump-schemas + + 8) Test a tool invocation + ./didactyl --test-tool config_recall '{"config_name":"llm_config"}' diff --git a/exit_code.txt b/exit_code.txt new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/exit_code.txt @@ -0,0 +1 @@ +1 diff --git a/help_output.txt b/help_output.txt new file mode 100644 index 0000000..8f04bd0 --- /dev/null +++ b/help_output.txt @@ -0,0 +1,70 @@ +Didactyl - sovereign Nostr agent + +Usage: + ./didactyl [options] + +Options: + -h, --help + Show this help text and exit. + -v, --version + Show version and exit. + --config + Path to config/genesis JSONC file (default: ./genesis.jsonc). + If file is missing, startup falls back to minimal nsec-only mode. + --nsec + Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC. + --admin + Administrator pubkey override; accepts npub1... or 64-char hex. + --api-port + Enable HTTP API and bind to this port (1-65535). + --api-bind + Enable HTTP API and bind address (example: 127.0.0.1). + --debug <0-5> + Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace). + --signer + Signer provider: local | nsigner_unix | nsigner_tcp. + Overrides signer.mode in config and DIDACTYL_SIGNER. + In nsigner_* modes --nsec/DIDACTYL_NSEC are not required. + --signer-socket + Abstract socket name for nsigner_unix (without leading @). + --signer-role + n_signer role selector (default: main). + --signer-timeout + Per-call signer timeout in milliseconds (default: 15000). + --signer-tcp + Shorthand for --signer nsigner_tcp with host/port parsed. + --dump-schemas + Print tool schemas JSON and exit. + --test-tool + Execute one tool call locally and print JSON result, then exit. + +Environment: + DIDACTYL_NSEC + Fallback private key when --nsec is not provided (local mode only). + DIDACTYL_SIGNER + Fallback signer mode when --signer is not provided. + +Examples: + 1) Genesis-based startup + ./didactyl --config genesis.jsonc + + 2) nsec-only startup (no config file) + ./didactyl --nsec nsec1... + + 3) nsec via environment variable + DIDACTYL_NSEC=nsec1... ./didactyl + + 4) Override API bind/port + ./didactyl --config genesis.jsonc --api-bind 127.0.0.1 --api-port 8484 + + 5) Sign with a running n_signer (separate the nsec from the agent) + ./didactyl --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1... + + 6) n_signer via TCP + ./didactyl --signer-tcp 127.0.0.1:7777 --admin npub1... + + 7) Dump tool schemas + ./didactyl --dump-schemas + + 8) Test a tool invocation + ./didactyl --test-tool config_recall '{"config_name":"llm_config"}' diff --git a/nostr_core_lib.old b/nostr_core_lib.old new file mode 160000 index 0000000..af06f23 --- /dev/null +++ b/nostr_core_lib.old @@ -0,0 +1 @@ +Subproject commit af06f23939d97b81f91603931844500b7a91d13a diff --git a/nsigner.log b/nsigner.log new file mode 100644 index 0000000..e514e23 --- /dev/null +++ b/nsigner.log @@ -0,0 +1,668 @@ +nsigner v0.0.33 +Seed phrase is valid and accepted. +System is ready and waiting for connections on @didactyl_test_signer. +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +(none) + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +(none) + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120741 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120741 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + + + + + + + + + + + + + + + + + + + + + + + + + ================================================================================ + n_signer v0.0.33 +================================================================================ +> Main Menu + + +Connections +listen: unix @didactyl_test_signer +client: nsigner --socket-name didactyl_test_signer client '' +fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28 +fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd + + q/x quit + l lock/reunlock + r refresh + A toggle auto-approve + +session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON +Roles +Role Purpose Curve Selector +-------------------- ------------ ------------ ------------ +main nostr secp256k1 nostr_index + +Activity (latest first) +0708-120750 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120741 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt +0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt + \ No newline at end of file diff --git a/nsigner.pid b/nsigner.pid new file mode 100644 index 0000000..703a441 --- /dev/null +++ b/nsigner.pid @@ -0,0 +1 @@ +131270 diff --git a/plans/nsigner_integration.md b/plans/nsigner_integration.md new file mode 100644 index 0000000..a52755a --- /dev/null +++ b/plans/nsigner_integration.md @@ -0,0 +1,440 @@ +# n_signer Integration Plan — didactyl + +## Goal + +Separate control of the nsec from the didactyl agent by routing signing/encryption +through `n_signer` — a separate foreground process that holds key material in locked +memory and signs on request. A later phase may embed n_signer into the didactyl binary +so one self-contained binary spawns an n_signer in a separate window; that is deferred +because of a reliability concern (if the agent loses signing ability it cannot even DM +its admin about the failure). + +This plan covers two distinct work items: + +1. **Update didactyl's vendored `nostr_core_lib`** to the upstream version that already + implements the nsigner client (M1–M6 complete, upstream `VERSION` 0.6.6). This is a + mechanical sync that changes no runtime behavior. +2. **Opt-in migration of didactyl call sites** to the `nostr_signer_t` provider so the + raw private key can leave the agent process. This is the actual nsec-separation work + and is staged so reliability is preserved. + +--- + +## Background — what upstream already provides + +Upstream `nostr_core_lib` (git.laantungir.net/nostr_core_lib, v0.6.6) has shipped the +full signer abstraction. didactyl's vendored copy predates all of it. + +New upstream modules: + +- [`nostr_core/nostr_signer.{h,c}`](../nostr_core_lib/nostr_core/nostr_signer.h) — + `nostr_signer_t` opaque handle + vtable. Two backends: + - **local** — `nostr_signer_local(privkey[32])`, wraps today's raw-key path. Output-equivalent. + - **remote (nsigner)** — `nostr_signer_nsigner_unix/serial/tcp/fds(...)`, guarded by + `NOSTR_ENABLE_NSIGNER_CLIENT`. +- [`nostr_core/nsigner_client.{h,c}`](../nostr_core_lib/nostr_core/nsigner_client.h) — + framing, JSON-RPC verbs, kind-27235 auth envelope, error mapping. +- [`nostr_core/nsigner_transport.{h,c}`](../nostr_core_lib/nostr_core/nsigner_transport.h) — + pluggable transport vtable: AF_UNIX abstract, TCP, USB-CDC serial, fd-pair (stdio/qrexec). + +Verbs mirror n_signer's wire contract: `get_public_key`, `sign_event`, +`nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`. + +Parallel `*_with_signer` entry points added across the library (legacy raw-key APIs are +preserved as thin wrappers over the local backend, so existing projects keep compiling +and behaving identically): + +- NIP-01: `nostr_create_and_sign_event_with_signer` +- NIP-17: `nostr_nip17_create_relay_list_event_with_signer`, + `nostr_nip17_send_dm_with_signer`, `nostr_nip17_receive_dm_with_signer` +- NIP-42: `nostr_nip42_create_auth_event_with_signer` +- NIP-46: request/response/decrypt/session-init `*_with_signer` variants +- NIP-59: seal/unwrap/unseal `*_with_signer` variants +- NIP-60 / NIP-61: all cashu event-creation `*_with_signer` variants +- Blossom: `blossom_create_auth_header_with_signer`, + `blossom_upload_with_signer`, `blossom_upload_file_with_signer`, + `blossom_delete_with_signer` +- Relay pool: `nostr_relay_pool_set_auth_with_signer` — the pool now stores an + `auth_signer` instead of a raw key, so NIP-42 AUTH events are signed through the + provider on relay challenge. **This was the hardest blocker and it is already solved + upstream.** + +Upstream `build.sh` unconditionally defines `-DNOSTR_ENABLE_NSIGNER_CLIENT=1` and +compiles `nostr_signer.c`, `nsigner_transport.c`, `nsigner_client.c`. + +Dependency direction is unchanged and non-circular: n_signer (server) depends on +`nostr_core_lib` for leaf crypto; `nostr_core_lib` gains a *client* that consumes its +own primitives. Nobody imports anyone's binary in a loop. + +--- + +## Current didactyl state (what we are updating from) + +- `nostr_core_lib/` is a **vendored source directory** (no git submodule; no + `.gitmodules`). Built via `./nostr_core_lib/build.sh --nips=001,004,...,061` into a + static `libnostr_core_*.a`, linked by [`Makefile`](../Makefile:56) and the Docker + static build ([`Dockerfile.alpine-musl`](../Dockerfile.alpine-musl:81)). +- The vendored tree has **none** of the nsigner pieces: no `nostr_signer.*`, + no `nsigner_*.*`, no `*_with_signer` variants. The relay pool still uses the raw-key + AUTH path ([`nostr_core_lib/nostr_core/core_relay_pool.c`](../nostr_core_lib/nostr_core/core_relay_pool.c:682)). +- didactyl holds the raw 32-byte key in [`cfg->keys.private_key`](../src/config.h:16) + and passes it directly into ~30+ library call sites (event signing, NIP-04/44 + encrypt/decrypt, NIP-17 gift wraps, NIP-60/61 cashu, blossom, relay pool AUTH). + +--- + +## Architecture (target) + +```mermaid +graph TD + A[didactyl src] --> B[nostr_core_lib vendored] + B --> C[nostr_signer_t local backend] + B --> D[nostr_signer_t nsigner remote backend] + D --> E[nsigner client: framing + auth envelope] + E --> F[nostr_create_and_sign_event in nostr_core_lib] + G[n_signer server binary] --> B +``` + +didactyl constructs **one** `nostr_signer_t*` at startup and threads it through every +signing/encryption/AUTH call site. The backend is chosen by config: + +- `signer.mode = "local"` → `nostr_signer_local(cfg->keys.private_key)` (today's behavior). +- `signer.mode = "nsigner_unix"` → `nostr_signer_nsigner_unix(socket_name, role, timeout_ms)`. +- (future) `nsigner_tcp`, `nsigner_serial`, `nsigner_fds`. + +The raw `cfg->keys.private_key` is only populated in `local` mode. In remote modes the +agent process never holds the nsec; it learns its own pubkey via +`nostr_signer_get_public_key()`. + +--- + +## Phasing + +### Phase 1 — Update vendored nostr_core_lib (mechanical, zero behavior change) + +Goal: bring didactyl's `nostr_core_lib` source tree to upstream v0.6.6 so the signer +abstraction and nsigner client are available. **No didactyl source changes.** Existing +raw-privkey APIs are preserved upstream as thin wrappers over the local backend, so all +~30 call sites keep compiling and behaving identically. + +Steps: + +1. Snapshot current vendored tree for diffing/rollback: + `cp -a nostr_core_lib nostr_core_lib.old` +2. Replace the vendored source tree with upstream v0.6.6. Preferred: copy the upstream + `nostr_core/`, `cjson/`, `nostr_websocket/`, `platform/`, `build.sh`, `CMakeLists.txt` + into `nostr_core_lib/`, preserving didactyl-local build quirks only if any exist + (none found — the Dockerfile already `sed`s `build.sh` in-place for fortification). + Do **not** carry over upstream's prebuilt `.a`/`.o` files, `pool.log`, `.venv*`, + `backups/`, `verify_*` artifacts — match didactyl's existing `.gitignore` hygiene. +3. Rebuild the static library: + `cd nostr_core_lib && ./build.sh --nips=001,004,005,006,011,013,017,019,021,042,044,046,059,060,061` + (same NIP set as today; `nostr_signer.c` / `nsigner_*.c` are compiled unconditionally + by upstream `build.sh`). +4. Rebuild didactyl: `make clean && make`. Confirm it compiles and links with no source + changes. +5. Smoke test: run the agent with an nsec-only config and confirm signing/DMs/relay AUTH + still work byte-for-byte (the upstream local backend is output-equivalent to the + legacy path; there is an upstream test `test_event_creation_equivalence_with_signer` + that asserts this). +6. Static (Docker) build: `./build_static.sh`. The Dockerfile copies `nostr_core_lib/` + and runs `./build.sh --nips=all`; verify the static binary still builds and runs. +7. Record the upstream version pinned in this repo (e.g. a one-line + `nostr_core_lib/UPSTREAM_VERSION` file = `0.6.6`) so future syncs are auditable. + +Exit criteria: didactyl builds and runs identically, but now links a `nostr_core_lib` +that exposes `nostr_signer_t` and the nsigner client. No nsec separation yet. + +### Phase 2 — Signer provider in didactyl config + startup (local backend, still no separation) + +Goal: introduce a `nostr_signer_t*` in didactyl constructed at startup, backed by +`local` mode by default. This is the seam; behavior is still identical. + +#### 2a. Config schema + +[`src/config.c`](../src/config.c) / [`src/config.h`](../src/config.h): add a `signer` +block (sibling of `keys`): + +```jsonc +"signer": { + "mode": "local", // "local" | "nsigner_unix" | (future) "nsigner_tcp" | "nsigner_serial" | "nsigner_fds" + "socket_name": "", // nsigner_unix: abstract socket name (without @); empty = auto-discover single running signer + "role": "main", // n_signer role selector + "timeout_ms": 15000, + "auth_privkey_hex": "" // optional, for TCP auth envelope only +} +``` + +Default `mode = "local"`. Backward compatible: if `signer` block is absent, behave as +`local` using `keys.nsec`. Validation: in any `nsigner_*` mode, `keys.nsec` is optional +and ignored (the agent learns its pubkey from the signer); in `local` mode `keys.nsec` +remains required. + +#### 2b. User-facing surfaces (how the operator chooses n_signer) + +didactyl has three startup surfaces today; the n_signer choice is exposed on all three, +consistent with existing patterns: + +**1. Interactive setup wizard** ([`src/setup_wizard.c`](../src/setup_wizard.c), +`new_agent_identity_step` at line ~2116). Today the Identity page offers: +`[g]enerate new keypair` / `[p]rovide existing nsec`. Add a third option: + +- `[s]ign with a running n_signer (separate the nsec from the agent)` + +When chosen, the wizard: +- Does **not** prompt for an nsec. Instead prompts for: + - socket name (default: auto-discover the single running signer), + - role (default `main`), + - timeout (default 15000ms). +- Performs a live `nostr_signer_nsigner_unix(...)` + `nostr_signer_get_public_key()` + connectivity check immediately, so the operator gets confirmation ("Connected to + n_signer @nsigner_hairy_dog; agent npub: npub1...") before proceeding. +- Writes `signer.mode = "nsigner_unix"` (plus socket_name/role/timeout) into the + generated genesis JSONC and **omits** `keys.nsec`. +- The existing `[p]rovide existing nsec` path stays as `local` mode (the default). + +This keeps the trust model visible at the moment the operator commits to an identity: +either the nsec lives in the agent (`local`) or it does not (`nsigner_unix`). + +**2. CLI flags + env** ([`src/main.c`](../src/main.c) `print_usage` at line ~141 and +arg parsing at line ~1233). Add: + +- `--signer ` — one of `local`, `nsigner_unix`, `nsigner_tcp`, `nsigner_serial`, + `nsigner_fds`. Overrides `signer.mode` in config. +- `--signer-socket ` — abstract socket name (nsigner_unix). +- `--signer-role ` — n_signer role selector (default `main`). +- `--signer-timeout ` — per-call timeout (default 15000). +- `--signer-tcp ` — shorthand for `--signer nsigner_tcp` + host/port. +- `DIDACTYL_SIGNER` env — fallback for `--signer` (mirrors the `DIDACTYL_NSEC` pattern). + +When `--signer nsigner_unix` (or `DIDACTYL_SIGNER=nsigner_unix`) is set, `--nsec` / +`DIDACTYL_NSEC` are not required and are ignored if present. This enables headless / +systemd deployment where the nsec never touches the agent's argv or environment. + +Update `print_usage` examples to show the n_signer path, e.g.: +`%s --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1...` + +**3. Genesis JSONC config** — the `signer` block in 2a is the canonical, persistent +form. The wizard writes it; CLI flags override it for a single run; env vars are the +last fallback. This matches how `keys.nsec` / `--nsec` / `DIDACTYL_NSEC` already layer. + +Precedence (highest to lowest): CLI flag → env var → genesis `signer` block → default +`local`. Same precedence as the existing key/admin overrides. + +#### 2c. Startup construction + +[`src/main.c`](../src/main.c): after loading config + applying CLI/env overrides, +construct a single `nostr_signer_t* g_signer`: + +- `local` → `nostr_signer_local(cfg->keys.private_key)`. +- `nsigner_unix` → `nostr_signer_nsigner_unix(socket_name, role, timeout_ms)`. +- (future) `nsigner_tcp` / `nsigner_serial` / `nsigner_fds` analogously. + +Then derive the agent pubkey via `nostr_signer_get_public_key(g_signer, ...)` instead of +`nostr_ec_public_key_from_private_key(cfg->keys.private_key, ...)`. In `local` mode this +yields the same bytes; in remote mode it is the first n_signer round-trip and **doubles +as the mandatory startup connectivity check** (Phase 4): failure here is fatal and loud. + +Hold `g_signer` alongside `g_cfg` / `g_pool` for the process lifetime; free on shutdown. + +Exit criteria: didactyl runs with a `nostr_signer_t*` in `local` mode by default, and +the operator can choose `nsigner_unix` via wizard, CLI, or config. No call sites migrated +yet (Phase 3). This is a safe, reviewable checkpoint — in `local` mode behavior is +identical; in `nsigner_unix` mode the agent starts but signing still uses the local path +until Phase 3 lands (so Phase 2 remote mode is a connectivity proof, not full separation). + +### Phase 3 — Migrate call sites to `*_with_signer` (enables real separation) + +Goal: route every signing/encryption/AUTH operation through `g_signer`. In `local` mode +this is still output-equivalent; in `nsigner_unix` mode the nsec is no longer in the +agent process. + +Migrate in this order (highest value / lowest risk first): + +1. **Relay pool AUTH** — replace + `nostr_relay_pool_set_auth(g_pool, g_cfg->keys.private_key, 1)` with + `nostr_relay_pool_set_auth_with_signer(g_pool, g_signer, 1)`. This is the single + change that delegates the deep-event-loop AUTH signing. +2. **Event signing** — `nostr_create_and_sign_event(...)` → + `nostr_create_and_sign_event_with_signer(...)` at all sites (posts, kind 30078 + config publish, startup events, etc.). Search: `nostr_create_and_sign_event` in + `src/`. +3. **NIP-44 encrypt/decrypt** — the DM/memory/task/config/block-list sites. These call + `nostr_nip44_encrypt/decrypt(cfg->keys.private_key, peer, ...)`. Replace with + `nostr_signer_nip44_encrypt/decrypt(g_signer, peer_hex, ...)`. Note the signer API + takes hex pubkeys and returns heap strings via `char**` out-params, so each call site + needs a small adaptation (hex-encode the peer key, free the out-string). +4. **NIP-04 encrypt/decrypt** — same pattern as NIP-44 for the legacy NIP-04 DM sites. +5. **NIP-17 gift wraps** — `nostr_nip17_send_dm` / `nostr_nip17_receive_dm` → + `*_with_signer`. (The seal is signed with the agent key via the signer; the gift-wrap + ephemeral key stays local — that is correct and does not leak the nsec.) +6. **NIP-60/61 cashu** — `nostr_nip60_create_*_event` / `nostr_nip61_create_*_event` → + `*_with_signer` in [`src/cashu_wallet.c`](../src/cashu_wallet.c). +7. **Blossom** — `blossom_create_auth_header` / `blossom_upload` / `blossom_delete` → + `*_with_signer` in [`src/tools/tool_blossom.c`](../src/tools/tool_blossom.c). + +After each group, build + smoke test in `local` mode (output-equivalent) before +testing in `nsigner_unix` mode against a running `n_signer`. + +Exit criteria: every signing/encryption/AUTH op flows through `g_signer`. With +`signer.mode = "nsigner_unix"`, the agent process holds no nsec. + +### Phase 4 — Reliability & failure handling (critical, do before enabling remote by default) + +The user's stated concern: if the agent loses signing ability, it cannot even DM its +admin about the failure. This must be addressed before remote mode is anything but +opt-in. + +Design: + +- **Startup connectivity check is mandatory.** `nostr_signer_get_public_key()` is called + at startup for every mode. In remote mode, failure here is fatal and loud (agent + refuses to start, prints a clear error). Better to fail to start than to start unable + to sign. +- **Runtime signing failure** — classify by error: + - `approval_denied` / `policy_denied` / `unknown_role` → not a transient error; surface + to agent log and admin context, do not retry blindly. + - transport/IO error (n_signer crashed, socket gone) → the nsigner transport has a + `reconnect` hook; the client retries once with reconnect, then returns an error. +- **Admin notification path.** Because DMs themselves require signing, a signing outage + is a catch-22 for "DM the admin". Mitigations, in order of preference: + 1. **Health-gate before sending.** Before attempting an admin DM, do a cheap + `nostr_signer_get_public_key()` ping. If it fails, fall back to a **local, + keyless** notification channel: stderr/log + HTTP API health endpoint + ([`src/http_api.c`](../src/http_api.c)) exposes a `signer_unhealthy` flag. The admin + monitors the HTTP health endpoint (or process supervisor) rather than relying on a + DM that requires the broken signer. + 2. **Optional emergency local key.** A config-gated `signer.emergency_local_nsec` + used *only* to sign admin DMs when the remote signer is unreachable. This is a + deliberate policy tradeoff (it re-introduces a key in-process, scoped to admin + alerts only) and should be off by default. Document the tradeoff clearly. + 3. **Process supervisor.** systemd/`didactyl` service restarts on signer health + failure; the supervisor's logs are the out-of-band channel. +- **Recommendation for v1:** ship (1) + (3). Leave (2) as a documented opt-in for + operators who prioritize alert delivery over strict key separation. This keeps the + default trust model clean while making the failure observable out-of-band. + +Exit criteria: a signing outage is detectable out-of-band (HTTP health + logs + +supervisor), and the agent does not silently hang. Remote mode is still opt-in. + +#### Phase 4 implementation status (items 18–20) — COMPLETE + +Items 18, 19, and 20 are implemented as follows. Behavior is backward compatible +and remote mode remains opt-in. + +**Item 18 — Runtime signer health classification & tracking** + +- New module [`src/signer_health.{h,c}`](../src/signer_health.h) holds a + process-global, mutex-protected health state: `unknown | healthy | unhealthy`, + the configured signer mode snapshot, the last error reason string, and the + last error / last ok unix timestamps. +- [`src/main.c`](../src/main.c) `construct_signer()` records the mode at startup + and records `ok` on a successful connectivity check (`get_public_key`) or + `failure` (with the nostr error code) on construction/connectivity failure. + The fatal+loud startup behavior is preserved; error messages now explicitly + include `mode=`, `socket=`, `role=`, `timeout_ms=`, and `rc=` so the operator + knows exactly which signer context failed. +- The migrated signer call paths in [`src/nostr_handler.c`](../src/nostr_handler.c) + (NIP-44 self encrypt/decrypt, NIP-04 incoming DM decrypt, NIP-04 outgoing DM + encrypt) call `signer_health_record_ok()` on success and + `signer_health_record_failure(rc, "")` on failure. Classification maps + nostr error codes to short labels: `transport/IO` (IO/network), `crypto/auth`, + `resource`, `invalid-input`, `unknown`. This is deliberately minimal — no broad + rewrite of the tool/agent/cashu/block-list signer sites; the central DM and + self-config paths are instrumented, which is where an outage first surfaces. + +**Item 19 — Out-of-band health surface (HTTP + logs + supervisor)** + +- [`src/http_api.c`](../src/http_api.c) `handle_status()` (the `GET /api/status` + endpoint) now adds the following fields to the JSON response: + - `signer_mode` — the configured signer mode (`local` / `nsigner_unix` / `nsigner_tcp`). + - `signer_healthy` — `"true"` / `"false"` / `"unknown"`. + - `signer_health_state` — `healthy` / `unhealthy` / `unknown`. + - `signer_last_error` — human-readable `: (rc=)`, or `""`. + - `signer_last_error_ts` — unix timestamp of the last unhealthy transition, or `null`. + - `signer_last_ok_ts` — unix timestamp of the last healthy observation, or `null`. + An operator (or supervisor / monitoring agent) can poll `/api/status` to detect a + signing outage without requiring a signed admin DM to come back. +- `signer_health_record_failure()` and `signer_health_record_ok()` each emit a + one-line stderr warning on a health **transition** (healthy -> unhealthy, or + unhealthy -> healthy), including the mode and reason. Repeated failures within + the same unhealthy state do not spam the log. + +**Item 20 — Optional `emergency_local_nsec` (admin-DM delivery tradeoff)** + +- New config field `signer.emergency_local_nsec` (string, empty default) in + [`src/config.h`](../src/config.h) `signer_config_t`, parsed in + [`src/config.c`](../src/config.c) `parse_signer_config()`. +- **No active fallback behavior is wired yet.** The field is parsed, stored, and + documented only — a feature-flag/placeholder so operators can pre-stage the + value. The actual "sign a minimal admin-alert DM with the emergency key when + the remote signer is unreachable" path is deferred until the Phase 4 health + surface (item 19) is proven in production. +- **Tradeoff (documented here and in the config header comment):** off by default. + When eventually enabled, it reintroduces the nsec into the agent process + *only* for admin-alert fallback DMs. This is a deliberate operator-accepted + tradeoff: better to leak the nsec briefly to notify the admin than to fail + silently. Operators who prioritize strict key separation leave it empty and + rely on the HTTP health endpoint + supervisor logs (item 19) as the + out-of-band alert channel. + +**Validation** + +- `make clean && make` builds cleanly with the new `signer_health.c` source. +- `./didactyl --help` and `./didactyl --version` unchanged in surface. +- `GET /api/status` in local mode returns the new `signer_mode` / `signer_healthy` + / `signer_health_state` / `signer_last_error` / `signer_last_error_ts` / + `signer_last_ok_ts` fields (local mode reports `signer_healthy=true` after the + startup connectivity check). + +### Phase 5 (deferred) — Embedded n_signer spawn + +Goal: one self-contained didactyl binary that spawns an n_signer in a separate window +at startup, for operators who want key separation without running a second process +manually. + +Deferred because of the reliability concern above and because it couples didactyl's +process model to n_signer's TUI/terminal-attached trust model. Revisit after Phase 4's +health/observability story is proven in production with an external n_signer. + +Sketch (not for this round): didactyl forks `n_signer --mnemonic-fd N --socket-name ` +in a new terminal (xterm/foot/etc.), passes the mnemonic over a pipe fd, pins the +abstract socket name, and constructs `nostr_signer_nsigner_unix(, ...)`. +The pinned socket name avoids discovery races. The trust anchor is still the +terminal-attached n_signer process; didactyl is just a supervised spawner. + +--- + +## Open decisions (resolved) + +1. **Where does the abstraction live?** Inside `nostr_core_lib`. It is the single + chokepoint, it solves the relay-AUTH blocker, and it is reusable. ✓ (already done + upstream) +2. **How is the n_signer client code sourced into nostr_core_lib?** Already decided + upstream: the client (framing + auth envelope + transports) lives in + `nostr_core_lib` itself, guarded by `NOSTR_ENABLE_NSIGNER_CLIENT`. No vendoring of + n_signer's repo into nostr_core_lib; n_signer keeps depending on nostr_core_lib for + leaf crypto. ✓ +3. **Runtime failure handling.** See Phase 4: out-of-band health (HTTP + logs + + supervisor) by default; optional emergency-local-nsec as documented opt-in. ✓ + +--- + +## Risks & notes + +- **NIP set in `Makefile` vs `build.sh --nips=all` in Dockerfile.** Both must include + the NIPs didactyl uses. The signer core (`nostr_signer.c`, `nsigner_*.c`) is compiled + unconditionally by upstream `build.sh`, so the nsigner client is available in both + build paths. Verify after sync. +- **Static link footprint.** nsigner client + transports add a small amount of object + code (unix/tcp/serial/fd transports). Negligible vs. the existing websocket/curl/openssl + surface. Serial transport pulls in `` — already POSIX, fine for musl. +- **Output equivalence.** Upstream's local backend is asserted equivalent by + `test_event_creation_equivalence_with_signer`. Phase 1 smoke test should still + verify a known event signs to the same id as before. +- **`cfg->keys.private_key` lifetime.** In remote mode this buffer is zeroed/unused. + Keep the field for `local` mode and for the optional emergency key; do not remove it. +- **Hex pubkey plumbing.** The signer's encrypt/decrypt verbs take hex pubkeys and + return heap strings. Several didactyl sites currently pass raw 32-byte keys. Each + migrated site needs a small hex-encode + free adaptation. Mechanical but touches + many files — Phase 3 step 3/4 is the bulk of the work. diff --git a/schemas.json b/schemas.json new file mode 100644 index 0000000..66bec25 --- /dev/null +++ b/schemas.json @@ -0,0 +1 @@ +[{"type":"function","function":{"name":"nostr_post","description":"Publish a Nostr event to connected relays","parameters":{"type":"object","properties":{"kind":{"type":"integer","description":"Nostr event kind number (for example 1 for note, 7 for reaction, 30023 for longform)"},"content":{"type":"string","description":"Event content/body text to publish"},"tags":{"type":"array","description":"Optional Nostr tags array, e.g. [[\"d\",\"my-skill\"],[\"t\",\"nostr\"]]","items":{"type":"array","items":{"type":"string"}}}},"required":["kind","content"]}}},{"type":"function","function":{"name":"nostr_query","description":"Query events from relays using a Nostr filter","parameters":{"type":"object","properties":{"filter":{"type":"object","description":"Nostr filter object (authors, kinds, ids, since, until, limit, and tag filters)"},"timeout_ms":{"type":"integer","description":"Optional query timeout in milliseconds"}},"required":["filter"]}}},{"type":"function","function":{"name":"local_shell_exec","description":"Execute a shell command and return stdout/stderr","parameters":{"type":"object","properties":{"command":{"type":"string","description":"Shell command to execute in the configured working directory"}},"required":["command"]}}},{"type":"function","function":{"name":"local_file_read","description":"Read a local file as text from the configured working directory","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Path to file to read (relative to configured working directory)"},"max_bytes":{"type":"integer","description":"Optional maximum bytes to return"}},"required":["path"]}}},{"type":"function","function":{"name":"local_file_write","description":"Write text content to a local file in the configured working directory","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Path to file to write (relative to configured working directory)"},"content":{"type":"string","description":"Text content to write"},"append":{"type":"boolean","description":"When true, append to file instead of overwriting"}},"required":["path","content"]}}},{"type":"function","function":{"name":"nostr_post_readme","description":"Publish README.md as kind 30023 with deterministic d tag readme.md","parameters":{"type":"object","properties":{},"required":[]}}},{"type":"function","function":{"name":"nostr_delete","description":"Request deletion of one or more previously published events (NIP-09 kind 5)","parameters":{"type":"object","properties":{"event_ids":{"type":"array","description":"Event IDs to request deletion for","items":{"type":"string"}},"kinds":{"type":"array","description":"Optional kinds corresponding to event_ids entries","items":{"type":"integer"}},"reason":{"type":"string","description":"Optional human-readable reason"}},"required":["event_ids"]}}},{"type":"function","function":{"name":"nostr_react","description":"React to a Nostr event with like/dislike/emoji (NIP-25 kind 7)","parameters":{"type":"object","properties":{"event_id":{"type":"string","description":"Target event ID to react to"},"event_pubkey":{"type":"string","description":"Author pubkey of target event"},"event_kind":{"type":"integer","description":"Optional kind of target event"},"reaction":{"type":"string","description":"Reaction content such as +, -, ❤️, or emoji"}},"required":["event_id","event_pubkey"]}}},{"type":"function","function":{"name":"nostr_profile_get","description":"Look up a Nostr profile (kind 0 metadata) by pubkey","parameters":{"type":"object","properties":{"pubkey":{"type":"string","description":"Hex pubkey of the profile to fetch"}},"required":["pubkey"]}}},{"type":"function","function":{"name":"nostr_relay_status","description":"Get connection status and statistics for all relays","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_nip05_lookup","description":"Look up or verify a NIP-05 identifier (user@domain)","parameters":{"type":"object","properties":{"identifier":{"type":"string","description":"NIP-05 identifier in the form user@domain"},"pubkey":{"type":"string","description":"Optional expected pubkey to verify against lookup result"}},"required":["identifier"]}}},{"type":"function","function":{"name":"nostr_encode","description":"Encode a Nostr entity into nostr: URI (npub, note, nprofile, nevent, naddr)","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Encoding target: npub, note, nprofile, nevent, or naddr"},"hex":{"type":"string","description":"Hex pubkey or event id to encode"},"relays":{"type":"array","description":"Optional relay URLs included in nprofile/nevent/naddr encodings","items":{"type":"string"}},"kind":{"type":"integer","description":"Required for naddr: replaceable event kind"},"identifier":{"type":"string","description":"Required for naddr: replaceable event identifier (d tag)"}},"required":["type","hex"]}}},{"type":"function","function":{"name":"nostr_decode","description":"Decode a Nostr bech32/nostr: URI into components","parameters":{"type":"object","properties":{"uri":{"type":"string","description":"nostr: URI or bech32 value to decode"}},"required":["uri"]}}},{"type":"function","function":{"name":"nostr_dm_send","description":"Send a NIP-04 encrypted DM","parameters":{"type":"object","properties":{"recipient_pubkey":{"type":"string","description":"Recipient pubkey in hex"},"message":{"type":"string","description":"Plaintext message to encrypt and send"}},"required":["recipient_pubkey","message"]}}},{"type":"function","function":{"name":"nostr_relay_info","description":"Fetch NIP-11 relay information document","parameters":{"type":"object","properties":{"relay_url":{"type":"string","description":"Relay websocket URL (ws:// or wss://)"}},"required":["relay_url"]}}},{"type":"function","function":{"name":"nostr_subscription_status","description":"List currently managed runtime Nostr subscriptions and filters","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_subscription_set","description":"Update one managed runtime subscription by name (toggle enabled and/or replace filter)","parameters":{"type":"object","properties":{"name":{"type":"string","description":"Subscription name (e.g. admin_context_profile, agent_context_notes, dms_kind4)"},"enabled":{"type":"boolean","description":"Optional: enable or disable this subscription"},"filter":{"type":"object","description":"Optional: replacement Nostr filter object"}},"required":["name"]}}},{"type":"function","function":{"name":"nostr_encrypt","description":"Encrypt plaintext using NIP-44 for a recipient","parameters":{"type":"object","properties":{"recipient_pubkey":{"type":"string","description":"Recipient pubkey in hex"},"plaintext":{"type":"string","description":"Plaintext to encrypt with NIP-44"}},"required":["recipient_pubkey","plaintext"]}}},{"type":"function","function":{"name":"nostr_decrypt","description":"Decrypt NIP-44 ciphertext from a sender","parameters":{"type":"object","properties":{"sender_pubkey":{"type":"string","description":"Sender pubkey in hex"},"ciphertext":{"type":"string","description":"NIP-44 ciphertext payload to decrypt"}},"required":["sender_pubkey","ciphertext"]}}},{"type":"function","function":{"name":"nostr_dm_send_nip17","description":"Send a private DM using NIP-17 gift wrap protocol","parameters":{"type":"object","properties":{"recipient_pubkey":{"type":"string","description":"Recipient pubkey in hex"},"message":{"type":"string","description":"Message body to send in gift-wrap DM"},"subject":{"type":"string","description":"Optional subject label for the message"}},"required":["recipient_pubkey","message"]}}},{"type":"function","function":{"name":"nostr_list_manage","description":"Add/remove tag tuples in replaceable list events (NIP-51 style)","parameters":{"type":"object","properties":{"list_kind":{"type":"integer","description":"Replaceable list kind to edit (for example 10000 mute, 10001 pin, 10002 relay list)"},"action":{"type":"string","description":"Operation to perform: add or remove"},"items":{"type":"array","description":"Tag tuples to add/remove, e.g. [[\"p\",\"\"],[\"e\",\"\"]]","items":{"type":"array","items":{"type":"string"}}}},"required":["list_kind","action","items"]}}},{"type":"function","function":{"name":"nostr_block_list","description":"View blocked pubkeys, event IDs, or hashtags from the local encrypted kind-10000 block list cache","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Optional filter: all, p, e, or t (default all)"}}}}},{"type":"function","function":{"name":"nostr_block_edit","description":"Add or remove blocked tuples in kind-10000 block list; entries are private (encrypted) by default","parameters":{"type":"object","properties":{"action":{"type":"string","description":"add or remove"},"items":{"type":"array","items":{"type":"array","items":{"type":"string"}},"description":"Tag tuples like [[\"e\",\"\"],[\"p\",\"\"],[\"t\",\"spam\"]]"},"public":{"type":"boolean","description":"Optional, default false; when true add to public tags instead of encrypted private content"}},"required":["action","items"]}}},{"type":"function","function":{"name":"agent_version","description":"Return current Didactyl version and metadata from build macros","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"local_http_fetch","description":"Fetch HTTP(S) resources with optional method, headers, timeout, and body","parameters":{"type":"object","properties":{"url":{"type":"string","description":"HTTP(S) URL to request"},"method":{"type":"string","description":"Optional method such as GET, POST, PUT, PATCH, or DELETE (default GET)"},"headers":{"type":"array","description":"Optional headers as [\"Name: value\", ...]","items":{"type":"string"}},"body":{"type":"string","description":"Optional request body for non-GET methods"},"timeout_seconds":{"type":"integer","description":"Optional timeout in seconds"},"max_bytes":{"type":"integer","description":"Optional maximum response bytes to capture"}},"required":["url"]}}},{"type":"function","function":{"name":"skill_create","description":"Create or update a skill definition as kind 31123/31124 and optionally auto-adopt it. Write content as markdown context instructions (no role markers). The runtime owns role packaging and document H1, and will bump skill headings down one level, so prefer ## for top-level sections in skill content. For auto-run behavior, set trigger + filter together.","parameters":{"type":"object","properties":{"d":{"type":"string"},"content":{"type":"string","description":"Markdown template for the skill body. Use plain markdown headings/lists/code fences and template variables like {{message}}, {{dm_history}}, {{agent_identity}}, {{admin_profile}}, or {{skill_d_tag}} references. Do not author system/user/assistant role markers in skill content; runtime appends trigger payloads as user input and handles role packaging. Runtime prepends document title and bumps headings one level."},"scope":{"type":"string"},"description":{"type":"string"},"auto_adopt":{"type":"boolean"},"trigger":{"type":"string","description":"Optional auto-run trigger. Must be one of: dm, cron, nostr-subscription, webhook, chain. If provided, filter is also required."},"filter":{"type":"string","description":"Trigger-specific filter string. Required when trigger is provided. Examples: dm=>{\"from\":\"admin\"}, cron=>\"0 12 * * *\", chain=>\"source_d_tag\", webhook=>\"{}\", nostr-subscription=>JSON filter."},"action":{"type":"string"},"enabled":{"type":"boolean"},"llm":{"type":"string","description":"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only."},"tools":{"type":"string"},"max_tokens":{"type":"integer"},"temperature":{"type":"number"},"seed":{"type":"integer"}},"required":["d","content"]}}},{"type":"function","function":{"name":"skill_list","description":"List available skills discovered online (agent + admin), with adoption status and optional filters","parameters":{"type":"object","properties":{"scope":{"type":"string"},"adopted":{"type":"boolean"}}}}},{"type":"function","function":{"name":"skill_get","description":"Get full skill JSON by d tag from local cache; JSON: {\"d\":\"identity_and_rules\"} (slash shorthand also works: /skill_get identity_and_rules)","parameters":{"type":"object","properties":{"d":{"type":"string","description":"Skill d tag (preferred JSON key)"},"input":{"type":"string","description":"Slash shorthand fallback key when args are passed as plain text"}},"required":["d"]}}},{"type":"function","function":{"name":"skill_view","description":"Compatibility alias for skill_get; JSON: {\"d\":\"identity_and_rules\"}","parameters":{"type":"object","properties":{"d":{"type":"string"},"input":{"type":"string"}},"required":["d"]}}},{"type":"function","function":{"name":"skill_set","description":"Set/publish a full skill JSON payload and refresh runtime cache; JSON: {\"skill\":{\"d\":\"identity_and_rules\",\"kind\":31124,\"scope\":\"private\",\"content\":\"...\",\"tags\":[[\"d\",\"identity_and_rules\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]}}","parameters":{"type":"object","properties":{"skill":{"type":"object","description":"Full skill object from skill_get output content"},"input":{"type":"string","description":"Raw JSON string for slash shorthand or manual paste"}}}}},{"type":"function","function":{"name":"skill_adopt","description":"Adopt a skill by adding its address to kind 10123 adoption list","parameters":{"type":"object","properties":{"pubkey":{"type":"string"},"d":{"type":"string"},"kind":{"type":"integer"}},"required":["pubkey","d"]}}},{"type":"function","function":{"name":"skill_remove","description":"Remove a skill address from kind 10123 adoption list","parameters":{"type":"object","properties":{"pubkey":{"type":"string"},"d":{"type":"string"},"kind":{"type":"integer"}},"required":["d"]}}},{"type":"function","function":{"name":"skill_edit","description":"Edit an existing self skill by d tag and republish it as kind 31123/31124. Keep skill content markdown-first (no role markers). Runtime owns role packaging, owns H1, and bumps skill headings down one level. For triggered skills, update trigger and filter together.","parameters":{"type":"object","properties":{"d":{"type":"string"},"content":{"type":"string","description":"Replacement markdown template for this skill. Use markdown structure and template variables such as {{message}}, {{dm_history}}, {{agent_identity}}, {{admin_profile}}, and {{skill_d_tag}} references. Do not include authored system/user/assistant role markers; runtime handles role packaging and user payload injection. If omitted, existing content is retained."},"scope":{"type":"string"},"description":{"type":"string"},"trigger":{"type":"string","description":"Optional trigger update. Valid values: dm, cron, nostr-subscription, webhook, chain. Must be paired with filter update when changing trigger config."},"filter":{"type":"string","description":"Trigger-specific filter update. Use with trigger. Examples: dm=>{\"from\":\"admin\"}, cron=>\"0 12 * * *\", chain=>\"source_d_tag\", webhook=>\"{}\", nostr-subscription=>JSON filter."},"action":{"type":"string"},"enabled":{"type":"boolean"},"llm":{"type":"string","description":"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only."},"tools":{"type":"string"},"max_tokens":{"type":"integer"},"temperature":{"type":"number"},"seed":{"type":"integer"}},"required":["d"]}}},{"type":"function","function":{"name":"skill_refresh","description":"Invalidate adopted skills cache so next context build reloads latest skill events from cache/relays","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"skill_search","description":"Search public skills by query/author and optionally rank by adoption popularity","parameters":{"type":"object","properties":{"query":{"type":"string"},"pubkey":{"type":"string"},"popular":{"type":"boolean"}}}}},{"type":"function","function":{"name":"skill_run","description":"Execute a skill one-shot in an isolated context with optional sandboxing. Use d_tag for own skills or pubkey+d_tag for external skills.","parameters":{"type":"object","properties":{"d_tag":{"type":"string","description":"Skill d tag identifier"},"d":{"type":"string","description":"Compatibility alias for d_tag"},"pubkey":{"type":"string","description":"Optional author pubkey for external skills"},"args":{"type":"string","description":"Arguments/context to pass to the skill"},"input":{"type":"string","description":"Slash shorthand fallback for args"},"sandbox":{"type":"boolean","description":"Override sandbox behavior (default on for external skills)"}},"required":["d_tag"]}}},{"type":"function","function":{"name":"skill_load","description":"Load a skill's instructions into the current conversation context.","parameters":{"type":"object","properties":{"d_tag":{"type":"string","description":"Skill d tag identifier"},"d":{"type":"string","description":"Compatibility alias for d_tag"},"pubkey":{"type":"string","description":"Optional author pubkey for external skills"}},"required":["d_tag"]}}},{"type":"function","function":{"name":"trigger_list","description":"List active triggered skills and their runtime status","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_file_md_to_longform_post","description":"Read a markdown file and publish it as kind 30023 longform post; defaults d-tag to lowercase filename","parameters":{"type":"object","properties":{"file":{"type":"string"},"title":{"type":"string"},"image":{"type":"string"},"summary":{"type":"string"}},"required":["file"]}}},{"type":"function","function":{"name":"tool_list","description":"List available tools with name, description, and JSON parameter schema","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"model_get","description":"Get current active LLM runtime configuration (excluding API key)","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"model_set","description":"Update active LLM configuration and persist it to Nostr kind 30078 (d=llm_config)","parameters":{"type":"object","properties":{"provider":{"type":"string"},"api_key":{"type":"string"},"model":{"type":"string"},"base_url":{"type":"string"},"max_tokens":{"type":"integer"},"temperature":{"type":"number"}}}}},{"type":"function","function":{"name":"model_list","description":"List available model IDs using provider OpenAI-compatible /models endpoint","parameters":{"type":"object","properties":{"base_url":{"type":"string"}}}}},{"type":"function","function":{"name":"nostr_pubkey","description":"Return this agent's pubkey in hex format","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_npub","description":"Return this agent's pubkey encoded as npub bech32","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_pubkey","description":"Alias for nostr_pubkey: return this agent's pubkey in hex format","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_npub","description":"Alias for nostr_npub: return this agent's pubkey encoded as npub bech32","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"admin_identity","description":"Build admin identity context block from cached runtime metadata","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_admin_profile","description":"Build admin profile context block from cached kind 0 metadata","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_admin_contacts","description":"Build admin contacts context block from cached kind 3 contact list","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_admin_relays","description":"Build admin relay context block from cached kind 10002 data","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_admin_notes","description":"Build admin notes context block from cached kind 1 notes","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_agent_profile","description":"Build agent profile context block from cached kind 0 metadata","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_agent_contacts","description":"Build agent contacts context block from cached kind 3 contact list","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_agent_relays","description":"Build agent relay context block from cached kind 10002 data","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_agent_notes","description":"Build agent notes context block from cached kind 1 notes","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_kind0_profile","description":"Alias for nostr_agent_profile: return this agent's kind 0 profile context","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_contacts","description":"Alias for nostr_agent_contacts: return this agent's kind 3 contacts context","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_relays","description":"Alias for nostr_agent_relays: return this agent's kind 10002 relay context","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"my_notes","description":"Alias for nostr_agent_notes: return this agent's recent kind 1 notes context","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"task_list","description":"Build current task list context block from agent task memory on Nostr","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"nostr_dm_history","description":"Fetch recent DM history for template/context usage; JSON: {\"limit\":12,\"include_current\":false,\"format\":\"json\"} or with explicit peer {\"peer_pubkey\":\"\",\"limit\":20,\"format\":\"text\"}; template usage: {{nostr_dm_history({\"limit\":12,\"format\":\"text\"})}}","parameters":{"type":"object","properties":{"limit":{"type":"integer","description":"Max message turns to return (1-200, default 12)"},"include_current":{"type":"boolean","description":"When true, keep a trailing user message that matches the current live message"},"peer_pubkey":{"type":"string","description":"Optional peer pubkey hex (defaults to configured admin pubkey)"},"format":{"type":"string","description":"Output format: 'json' (default) or 'text' (User:/Assistant: lines)"}}}}},{"type":"function","function":{"name":"agent_identity","description":"Build agent identity context block with pubkey and npub","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"task_manage","description":"Manage agent short-term task memory stored on Nostr kind 30078 (d=tasks): list/add/update/remove/clear/replace","parameters":{"type":"object","properties":{"action":{"type":"string","enum":["list","add","update","remove","clear","replace"]},"text":{"type":"string"},"id":{"type":"integer"},"status":{"type":"string","enum":["pending","active","done"]},"tasks":{"type":"array","items":{"type":"string"}}},"required":["action"]}}},{"type":"function","function":{"name":"memory_save","description":"Prepend a new entry to encrypted agent memory (kind 30078, d=memory) and truncate oldest content if needed","parameters":{"type":"object","properties":{"content":{"type":"string"}},"required":["content"]}}},{"type":"function","function":{"name":"memory_recall","description":"Recall encrypted agent memory (kind 30078, d=memory)","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"config_store","description":"Encrypt and publish agent config as kind 30078 for a given d_tag","parameters":{"type":"object","properties":{"d_tag":{"type":"string"},"content":{"description":"Arbitrary JSON value or string to encrypt and store"}},"required":["d_tag","content"]}}},{"type":"function","function":{"name":"config_recall","description":"Fetch and decrypt agent config kind 30078 by d_tag","parameters":{"type":"object","properties":{"d_tag":{"type":"string"}},"required":["d_tag"]}}},{"type":"function","function":{"name":"nostr_my_events","description":"Query recent events authored by this agent and return kind, event_id, timestamp, d_tag, and cache presence","parameters":{"type":"object","properties":{"limit":{"type":"integer","description":"Maximum number of authored events to query (1-2000, default 200)"},"kind":{"type":"integer","description":"Optional specific kind to query; omit for all kinds"},"timeout_ms":{"type":"integer","description":"Query timeout in milliseconds (1000-60000, default 8000)"}}}}},{"type":"function","function":{"name":"cashu_wallet_balance","description":"Return current wallet balances aggregated by mint and unit","parameters":{"type":"object","properties":{}}}},{"type":"function","function":{"name":"cashu_wallet_info","description":"Fetch mint info for a specific mint_url or the default configured mint","parameters":{"type":"object","properties":{"mint_url":{"type":"string","description":"Optional mint base URL; uses configured default when omitted"}}}}},{"type":"function","function":{"name":"cashu_wallet_mint_quote","description":"Request a mint quote for a target amount","parameters":{"type":"object","properties":{"amount":{"type":"integer","description":"Amount to mint in the selected unit"},"mint_url":{"type":"string","description":"Optional mint base URL; uses configured default when omitted"},"unit":{"type":"string","description":"Optional unit, e.g. sat"}},"required":["amount"]}}},{"type":"function","function":{"name":"cashu_wallet_mint_check","description":"Check a previously requested mint quote status","parameters":{"type":"object","properties":{"quote_id":{"type":"string"},"mint_url":{"type":"string"}},"required":["quote_id"]}}},{"type":"function","function":{"name":"cashu_wallet_mint_claim","description":"Claim minted proofs for a paid quote_id and add them to wallet state","parameters":{"type":"object","properties":{"quote_id":{"type":"string"},"mint_url":{"type":"string"},"amount":{"type":"integer","description":"Optional target amount; if omitted wallet attempts best-effort claim"}},"required":["quote_id"]}}},{"type":"function","function":{"name":"cashu_wallet_melt_quote","description":"Request a melt quote for a Lightning payment request","parameters":{"type":"object","properties":{"payment_request":{"type":"string"},"mint_url":{"type":"string"},"unit":{"type":"string"}},"required":["payment_request"]}}},{"type":"function","function":{"name":"cashu_wallet_melt_pay","description":"Pay a previously quoted melt by quote_id","parameters":{"type":"object","properties":{"quote_id":{"type":"string"},"mint_url":{"type":"string"}},"required":["quote_id"]}}},{"type":"function","function":{"name":"cashu_wallet_check_proofs","description":"Check current wallet proofs state against mint","parameters":{"type":"object","properties":{"mint_url":{"type":"string"}}}}},{"type":"function","function":{"name":"cashu_wallet_receive_token","description":"Receive an ecash token string (cashuA/cashuB), swap to fresh proofs, and store it in wallet state","parameters":{"type":"object","properties":{"token":{"type":"string","description":"Incoming token string beginning with cashuA or cashuB"},"mint_url":{"type":"string","description":"Optional mint URL override; when provided must match token mint"}},"required":["token"]}}},{"type":"function","function":{"name":"cashu_wallet_send_token","description":"Create an outbound ecash token from wallet proofs for an exact amount","parameters":{"type":"object","properties":{"amount":{"type":"integer","description":"Exact amount to send as ecash token"},"mint_url":{"type":"string","description":"Optional mint URL; defaults to configured wallet mint when omitted"},"format":{"type":"string","description":"Optional token format: cashuA or cashuB (default cashuB)"}},"required":["amount"]}}},{"type":"function","function":{"name":"cashu_wallet_mints_set","description":"Set wallet mints (NIP-60), public mints (NIP-61), or both","parameters":{"type":"object","properties":{"target":{"type":"string","description":"Where to apply mint updates: wallet, public, or both"},"wallet_mints":{"type":"array","items":{"type":"string"},"description":"Mint URLs for wallet event update"},"public_mints":{"type":"array","items":{"type":"string"},"description":"Mint URLs for public nutzap info update"}},"required":["target"]}}},{"type":"function","function":{"name":"cashu_wallet_mints_get","description":"Get currently configured wallet and public mints","parameters":{"type":"object","properties":{},"required":[]}}},{"type":"function","function":{"name":"blossom_upload","description":"Upload a local file to a Blossom server","parameters":{"type":"object","properties":{"server":{"type":"string"},"file_path":{"type":"string"},"content_type":{"type":"string"}},"required":["server","file_path"]}}},{"type":"function","function":{"name":"blossom_download","description":"Download a blob from Blossom to a local file","parameters":{"type":"object","properties":{"server":{"type":"string"},"sha256":{"type":"string"},"output_path":{"type":"string"},"overwrite":{"type":"boolean"}},"required":["server","sha256","output_path"]}}},{"type":"function","function":{"name":"blossom_head","description":"Fetch blob metadata from Blossom","parameters":{"type":"object","properties":{"server":{"type":"string"},"sha256":{"type":"string"}},"required":["server","sha256"]}}},{"type":"function","function":{"name":"blossom_delete","description":"Delete a blob from Blossom","parameters":{"type":"object","properties":{"server":{"type":"string"},"sha256":{"type":"string"}},"required":["server","sha256"]}}},{"type":"function","function":{"name":"blossom_list","description":"List blobs for a pubkey on Blossom","parameters":{"type":"object","properties":{"server":{"type":"string"},"pubkey":{"type":"string"}},"required":["server"]}}}] diff --git a/src/agent.c b/src/agent.c index 4e4c27d..4d666d0 100644 --- a/src/agent.c +++ b/src/agent.c @@ -1728,6 +1728,10 @@ static __attribute__((unused)) int append_adopted_skills_context(cJSON* messages return rc; } +void agent_set_signer(nostr_signer_t* signer) { + tools_set_signer(&g_tools_ctx, signer); +} + int agent_init(didactyl_config_t* config) { if (!config) { return -1; diff --git a/src/agent.h b/src/agent.h index fee40ac..34c57c7 100644 --- a/src/agent.h +++ b/src/agent.h @@ -9,6 +9,10 @@ struct trigger_manager; int agent_init(didactyl_config_t* config); +/* Wire a process-lifetime signer handle (non-owning) into the agent's tools + * context so NIP-04/44 encrypt/decrypt tool call sites route through the + * signer. Must be called after agent_init. Caller retains ownership. */ +void agent_set_signer(nostr_signer_t* signer); void agent_set_trigger_manager(struct trigger_manager* trigger_manager); void agent_on_trigger(const char* skill_d_tag, const char* skill_content, diff --git a/src/cashu_wallet.c b/src/cashu_wallet.c index ecedda0..d4f22bd 100644 --- a/src/cashu_wallet.c +++ b/src/cashu_wallet.c @@ -19,6 +19,13 @@ #define CASHU_WALLET_QUERY_TIMEOUT_MS 6000 #define CASHU_WALLET_MAX_SPLIT_PARTS 64 +/* Optional process-lifetime signer for NIP-60/NIP-61 cashu event creation. + * Owned by main.c; non-owning here. When present, event creation is routed + * through the *_with_signer variants so the raw private key stays out of the + * agent process in remote-signer modes. When NULL, the legacy raw-key path is + * used (local-mode compatibility fallback). */ +static nostr_signer_t* g_cashu_signer = NULL; + typedef struct { char event_id[65]; nostr_nip60_token_data_t token; @@ -121,7 +128,12 @@ static int publish_from_signed_event_template(cJSON* ev_template, char out_event } static int publish_wallet_event_and_store_locked(const nostr_nip60_wallet_data_t* wallet_data, char out_event_id[65]) { - cJSON* ev = nostr_nip60_create_wallet_event(wallet_data, g_wallet.cfg->keys.private_key, time(NULL)); + cJSON* ev = NULL; + if (g_cashu_signer) { + ev = nostr_nip60_create_wallet_event_with_signer(wallet_data, g_cashu_signer, time(NULL)); + } else { + ev = nostr_nip60_create_wallet_event(wallet_data, g_wallet.cfg->keys.private_key, time(NULL)); + } if (!ev) return -1; int rc = publish_from_signed_event_template(ev, out_event_id); @@ -156,7 +168,12 @@ static int publish_wallet_event_and_store_locked(const nostr_nip60_wallet_data_t static int publish_token_event_and_store_locked(nostr_nip60_token_data_t* token_data, char out_event_id[65]) { if (!token_data) return -1; - cJSON* ev = nostr_nip60_create_token_event(token_data, g_wallet.cfg->keys.private_key, time(NULL)); + cJSON* ev = NULL; + if (g_cashu_signer) { + ev = nostr_nip60_create_token_event_with_signer(token_data, g_cashu_signer, time(NULL)); + } else { + ev = nostr_nip60_create_token_event(token_data, g_wallet.cfg->keys.private_key, time(NULL)); + } if (!ev) return -1; char event_id[65] = {0}; @@ -194,7 +211,12 @@ static int publish_history_event(nostr_nip60_direction_t direction, history.refs = &ref; history.ref_count = (ref.event_id[0] != '\0') ? 1 : 0; - cJSON* ev = nostr_nip60_create_history_event(&history, g_wallet.cfg->keys.private_key, time(NULL)); + cJSON* ev = NULL; + if (g_cashu_signer) { + ev = nostr_nip60_create_history_event_with_signer(&history, g_cashu_signer, time(NULL)); + } else { + ev = nostr_nip60_create_history_event(&history, g_wallet.cfg->keys.private_key, time(NULL)); + } if (!ev) return -1; int rc = publish_from_signed_event_template(ev, NULL); @@ -203,7 +225,12 @@ static int publish_history_event(nostr_nip60_direction_t direction, } static int publish_token_deletion_event(const char* event_id) { - cJSON* del = nostr_nip60_create_token_deletion(event_id, g_wallet.cfg->keys.private_key, time(NULL)); + cJSON* del = NULL; + if (g_cashu_signer) { + del = nostr_nip60_create_token_deletion_with_signer(event_id, g_cashu_signer, time(NULL)); + } else { + del = nostr_nip60_create_token_deletion(event_id, g_wallet.cfg->keys.private_key, time(NULL)); + } if (!del) return -1; int rc = publish_from_signed_event_template(del, NULL); cJSON_Delete(del); @@ -408,6 +435,15 @@ int cashu_wallet_init(didactyl_config_t* cfg) { return 0; } +void cashu_wallet_set_signer(nostr_signer_t* signer) { + /* Non-owning: the signer handle is constructed and freed by main.c. + * Stored under the wallet mutex for visibility/tearing safety even + * though the handle itself is process-lifetime and immutable. */ + pthread_mutex_lock(&g_wallet.mutex); + g_cashu_signer = signer; + pthread_mutex_unlock(&g_wallet.mutex); +} + void cashu_wallet_cleanup(void) { if (!g_wallet.initialized) return; @@ -2755,7 +2791,12 @@ int cashu_wallet_mints_set(const char** wallet_mints, pthread_mutex_unlock(&g_wallet.mutex); if (rc != 0) return -1; - cJSON* ev = nostr_nip61_create_nutzap_info_event(&info, g_wallet.cfg->keys.private_key, time(NULL)); + cJSON* ev = NULL; + if (g_cashu_signer) { + ev = nostr_nip61_create_nutzap_info_event_with_signer(&info, g_cashu_signer, time(NULL)); + } else { + ev = nostr_nip61_create_nutzap_info_event(&info, g_wallet.cfg->keys.private_key, time(NULL)); + } nostr_nip61_free_nutzap_info(&info); if (!ev) return -1; diff --git a/src/cashu_wallet.h b/src/cashu_wallet.h index 8d0937d..bae8f7a 100644 --- a/src/cashu_wallet.h +++ b/src/cashu_wallet.h @@ -5,8 +5,10 @@ #include "config.h" #include "cjson/cJSON.h" +#include "../nostr_core_lib/nostr_core/nostr_signer.h" int cashu_wallet_init(didactyl_config_t* cfg); +void cashu_wallet_set_signer(nostr_signer_t* signer); void cashu_wallet_cleanup(void); int cashu_wallet_load_from_relays(void); diff --git a/src/config.c b/src/config.c index 5dadc2d..f760ba4 100644 --- a/src/config.c +++ b/src/config.c @@ -214,6 +214,97 @@ static int decode_pubkey_hex_or_npub(const char* in, char out_hex[65]) { return -1; } +/* Returns 1 if mode is a known signer mode, 0 otherwise. */ +static int signer_mode_is_known(const char* mode) { + if (!mode || mode[0] == '\0') { + return 0; + } + return strcmp(mode, "local") == 0 || + strcmp(mode, "nsigner_unix") == 0 || + strcmp(mode, "nsigner_tcp") == 0; +} + +/* Returns 1 if mode is a remote (nsigner_*) mode that does not require an + * in-process nsec, 0 otherwise. */ +static int signer_mode_is_remote(const char* mode) { + if (!mode || mode[0] == '\0') { + return 0; + } + return strcmp(mode, "nsigner_unix") == 0 || + strcmp(mode, "nsigner_tcp") == 0; +} + +static int parse_signer_config(cJSON* root, didactyl_config_t* config) { + cJSON* signer = cJSON_GetObjectItemCaseSensitive(root, "signer"); + if (!signer || !cJSON_IsObject(signer)) { + /* Missing signer block: defaults already applied by caller. */ + return 0; + } + + if (copy_json_string(signer, "mode", config->signer.mode, + sizeof(config->signer.mode), 0) != 0) { + config_set_error("signer.mode must be a string when provided"); + return -1; + } + if (config->signer.mode[0] != '\0' && !signer_mode_is_known(config->signer.mode)) { + config_set_error("signer.mode '%s' is not a known signer mode " + "(expected: local, nsigner_unix, nsigner_tcp)", + config->signer.mode); + return -1; + } + + if (copy_json_string(signer, "socket_name", config->signer.socket_name, + sizeof(config->signer.socket_name), 0) != 0) { + config_set_error("signer.socket_name must be a string when provided"); + return -1; + } + + if (copy_json_string(signer, "role", config->signer.role, + sizeof(config->signer.role), 0) != 0) { + config_set_error("signer.role must be a string when provided"); + return -1; + } + + cJSON* timeout = cJSON_GetObjectItemCaseSensitive(signer, "timeout_ms"); + if (timeout && cJSON_IsNumber(timeout)) { + int t = (int)timeout->valuedouble; + if (t > 0) { + config->signer.timeout_ms = t; + } + } + + if (copy_json_string(signer, "auth_privkey_hex", config->signer.auth_privkey_hex, + sizeof(config->signer.auth_privkey_hex), 0) != 0) { + config_set_error("signer.auth_privkey_hex must be a string when provided"); + return -1; + } + + /* Optional tcp host/port inside the signer block (alternative to --signer-tcp). */ + if (copy_json_string(signer, "tcp_host", config->signer.tcp_host, + sizeof(config->signer.tcp_host), 0) != 0) { + config_set_error("signer.tcp_host must be a string when provided"); + return -1; + } + cJSON* tcp_port = cJSON_GetObjectItemCaseSensitive(signer, "tcp_port"); + if (tcp_port && cJSON_IsNumber(tcp_port)) { + int p = (int)tcp_port->valuedouble; + if (p > 0 && p <= 65535) { + config->signer.tcp_port = p; + } + } + + /* Phase 4 item 20: optional emergency local nsec for admin-alert fallback. + * Parsed and stored only; no active fallback behavior is wired yet. */ + if (copy_json_string(signer, "emergency_local_nsec", + config->signer.emergency_local_nsec, + sizeof(config->signer.emergency_local_nsec), 0) != 0) { + config_set_error("signer.emergency_local_nsec must be a string when provided"); + return -1; + } + + return 0; +} + static int parse_tools_config(cJSON* root, didactyl_config_t* config) { cJSON* tools = cJSON_GetObjectItemCaseSensitive(root, "tools"); if (!tools || !cJSON_IsObject(tools)) { @@ -1491,6 +1582,18 @@ int config_load(const char* path, didactyl_config_t* config) { config->cashu_wallet.auto_load = 1; config->cashu_wallet.mint_timeout_seconds = 30; + /* Signer provider defaults: local mode, backward compatible. */ + snprintf(config->signer.mode, sizeof(config->signer.mode), "%s", "local"); + config->signer.socket_name[0] = '\0'; + snprintf(config->signer.role, sizeof(config->signer.role), "%s", "main"); + config->signer.timeout_ms = 15000; + config->signer.auth_privkey_hex[0] = '\0'; + config->signer.tcp_host[0] = '\0'; + config->signer.tcp_port = 0; + /* Phase 4 item 20: emergency local nsec placeholder. Off by default; + * no active fallback behavior yet (documentation/feature-flag only). */ + config->signer.emergency_local_nsec[0] = '\0'; + char* raw_buf = NULL; size_t raw_len = 0; if (read_file_to_buffer(path, &raw_buf, &raw_len) != 0) { @@ -1625,7 +1728,15 @@ int config_load(const char* path, didactyl_config_t* config) { goto cleanup; } - if (config->keys.nsec[0] != '\0') { + if (parse_signer_config(root, config) != 0) { + /* parse_signer_config sets a specific error message. */ + goto cleanup; + } + + /* Only decode the raw private key in local mode. In remote (nsigner_*) + * modes the agent process never holds the nsec; the pubkey is learned + * from the signer at startup via nostr_signer_get_public_key(). */ + if (!signer_mode_is_remote(config->signer.mode) && config->keys.nsec[0] != '\0') { if (decode_private_key(config->keys.nsec, config->keys.private_key) != 0) { config_set_error("keys.nsec/key.nsec must be valid nsec1... or 64-char hex private key"); goto cleanup; diff --git a/src/config.h b/src/config.h index caa5f6e..baff6a9 100644 --- a/src/config.h +++ b/src/config.h @@ -8,6 +8,11 @@ #define OW_MAX_URL_LEN 256 #define OW_MAX_KEY_LEN 256 #define OW_MAX_MODEL_LEN 128 +#define OW_MAX_SIGNER_MODE_LEN 32 +#define OW_MAX_SIGNER_SOCKET_LEN 128 +#define OW_MAX_SIGNER_ROLE_LEN 32 +#define OW_MAX_SIGNER_HOST_LEN 128 +#define OW_MAX_SIGNER_AUTH_HEX_LEN 65 #define OW_MAX_STRANGER_RESPONSE_LEN 512 @@ -116,6 +121,45 @@ typedef struct { int mint_timeout_seconds; } cashu_wallet_config_t; +/* Signer provider configuration. + * + * mode selects how signing/encryption operations are routed: + * "local" - raw 32-byte key held in cfg.keys.private_key (default, + * backward compatible, output-equivalent to legacy path). + * "nsigner_unix" - delegate to a running n_signer over an AF_UNIX abstract + * socket. The agent process holds no nsec. + * "nsigner_tcp" - delegate to a running n_signer over TCP (host:port). + * Requires auth_privkey_hex for the kind-27235 auth envelope. + * + * Missing signer block behaves as mode="local" using keys.nsec. + * In any nsigner_* mode, keys.nsec is optional and ignored; the agent pubkey + * is learned from the signer via nostr_signer_get_public_key(). + */ +typedef struct { + char mode[OW_MAX_SIGNER_MODE_LEN]; /* "local" | "nsigner_unix" | "nsigner_tcp" */ + char socket_name[OW_MAX_SIGNER_SOCKET_LEN]; /* nsigner_unix: abstract socket name (without @); "" = auto-discover */ + char role[OW_MAX_SIGNER_ROLE_LEN]; /* n_signer role selector (default "main") */ + int timeout_ms; /* per-call timeout (default 15000) */ + char auth_privkey_hex[OW_MAX_SIGNER_AUTH_HEX_LEN]; /* optional, TCP auth envelope only */ + /* nsigner_tcp parsed host/port (populated from --signer-tcp or config) */ + char tcp_host[OW_MAX_SIGNER_HOST_LEN]; + int tcp_port; + /* Optional emergency local nsec for admin-alert fallback (Phase 4 item 20). + * + * OFF by default (empty string). When populated AND a remote signer is + * unreachable, a future phase may use this in-process key ONLY to sign a + * minimal admin alert DM reporting the signer outage. This reintroduces + * the nsec into the agent process for that one purpose, which is a + * deliberate operator-accepted tradeoff: better to leak the nsec briefly + * to notify the admin than to fail silently. + * + * CURRENT STATUS: parsed and stored only. No active fallback behavior is + * implemented yet (feature flag / documentation placeholder). The field + * exists so operators can pre-stage the value; the fallback path is + * deferred until the supervisor/health surface (item 19) is proven. */ + char emergency_local_nsec[OW_MAX_KEY_LEN]; /* "" = disabled (default) */ +} signer_config_t; + typedef struct { agent_keys_t keys; admin_config_t admin; @@ -129,6 +173,7 @@ typedef struct { triggers_config_t triggers; api_config_t api; cashu_wallet_config_t cashu_wallet; + signer_config_t signer; startup_event_t* startup_events; int startup_event_count; encrypted_event_t* encrypted_events; diff --git a/src/http_api.c b/src/http_api.c index e17f97f..183b210 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -17,6 +17,7 @@ #include "cjson/cJSON.h" #include "debug.h" #include "mongoose.h" +#include "signer_health.h" static http_api_context_t g_api_ctx; static int g_api_initialized = 0; @@ -1228,6 +1229,39 @@ static void handle_status(struct mg_connection* c) { if (g_api_ctx.cfg) { cJSON_AddStringToObject(root, "pubkey", g_api_ctx.cfg->keys.public_key_hex); cJSON_AddNumberToObject(root, "relay_count", g_api_ctx.cfg->relay_count); + /* Phase 4 item 19: out-of-band signer health surface. Expose the + * configured signer mode and runtime health classification so an + * operator can detect a signing outage from the HTTP status endpoint + * without requiring a signed admin DM to come back. */ + cJSON_AddStringToObject(root, "signer_mode", + g_api_ctx.cfg->signer.mode[0] ? g_api_ctx.cfg->signer.mode : "local"); + } + + /* Signer runtime health (process-global, lock-protected snapshot). */ + signer_health_snapshot_t hs; + if (signer_health_snapshot(&hs) == 0) { + const char* state_str = "unknown"; + if (hs.state == SIGNER_HEALTH_HEALTHY) { + state_str = "healthy"; + } else if (hs.state == SIGNER_HEALTH_UNHEALTHY) { + state_str = "unhealthy"; + } + cJSON_AddStringToObject(root, "signer_healthy", + hs.state == SIGNER_HEALTH_HEALTHY ? "true" : + hs.state == SIGNER_HEALTH_UNHEALTHY ? "false" : "unknown"); + cJSON_AddStringToObject(root, "signer_health_state", state_str); + cJSON_AddStringToObject(root, "signer_last_error", + hs.last_reason[0] ? hs.last_reason : ""); + if (hs.last_error_ts > 0) { + cJSON_AddNumberToObject(root, "signer_last_error_ts", (double)hs.last_error_ts); + } else { + cJSON_AddNullToObject(root, "signer_last_error_ts"); + } + if (hs.last_ok_ts > 0) { + cJSON_AddNumberToObject(root, "signer_last_ok_ts", (double)hs.last_ok_ts); + } else { + cJSON_AddNullToObject(root, "signer_last_ok_ts"); + } } cJSON_AddNumberToObject(root, "connected_relays", nostr_handler_connected_relay_count()); diff --git a/src/main.c b/src/main.c index cdedb7a..6dbb3b7 100644 --- a/src/main.c +++ b/src/main.c @@ -25,6 +25,7 @@ #include "cashu_wallet.h" #include "nostr_block_list.h" #include "mongoose.h" +#include "signer_health.h" static volatile sig_atomic_t g_running = 1; @@ -163,6 +164,18 @@ static void print_usage(const char* prog) { " Enable HTTP API and bind address (example: 127.0.0.1).\n" " --debug <0-5>\n" " Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace).\n" + " --signer \n" + " Signer provider: local | nsigner_unix | nsigner_tcp.\n" + " Overrides signer.mode in config and DIDACTYL_SIGNER.\n" + " In nsigner_* modes --nsec/DIDACTYL_NSEC are not required.\n" + " --signer-socket \n" + " Abstract socket name for nsigner_unix (without leading @).\n" + " --signer-role \n" + " n_signer role selector (default: main).\n" + " --signer-timeout \n" + " Per-call signer timeout in milliseconds (default: 15000).\n" + " --signer-tcp \n" + " Shorthand for --signer nsigner_tcp with host/port parsed.\n" " --dump-schemas\n" " Print tool schemas JSON and exit.\n" " --test-tool \n" @@ -170,7 +183,9 @@ static void print_usage(const char* prog) { "\n" "Environment:\n" " DIDACTYL_NSEC\n" - " Fallback private key when --nsec is not provided.\n" + " Fallback private key when --nsec is not provided (local mode only).\n" + " DIDACTYL_SIGNER\n" + " Fallback signer mode when --signer is not provided.\n" "\n" "Examples:\n" " 1) Genesis-based startup\n" @@ -185,10 +200,16 @@ static void print_usage(const char* prog) { " 4) Override API bind/port\n" " %s --config genesis.jsonc --api-bind 127.0.0.1 --api-port 8484\n" "\n" - " 5) Dump tool schemas\n" + " 5) Sign with a running n_signer (separate the nsec from the agent)\n" + " %s --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1...\n" + "\n" + " 6) n_signer via TCP\n" + " %s --signer-tcp 127.0.0.1:7777 --admin npub1...\n" + "\n" + " 7) Dump tool schemas\n" " %s --dump-schemas\n" "\n" - " 6) Test a tool invocation\n" + " 8) Test a tool invocation\n" " %s --test-tool config_recall '{\"config_name\":\"llm_config\"}'\n", p, p, @@ -196,9 +217,210 @@ static void print_usage(const char* prog) { p, p, p, + p, + p, p); } +/* Parse "host:port" into host buffer and port out-param. + * Returns 0 on success, -1 on malformed input. */ +static int parse_host_port(const char* host_port, char* host_out, size_t host_size, int* port_out) { + if (!host_port || !host_out || !port_out || host_size == 0) { + return -1; + } + const char* colon = strrchr(host_port, ':'); + if (!colon || colon == host_port) { + return -1; + } + size_t host_len = (size_t)(colon - host_port); + if (host_len == 0 || host_len >= host_size) { + return -1; + } + memcpy(host_out, host_port, host_len); + host_out[host_len] = '\0'; + int port = atoi(colon + 1); + if (port <= 0 || port > 65535) { + return -1; + } + *port_out = port; + return 0; +} + +/* Returns 1 if the signer mode is a remote (nsigner_*) mode, 0 otherwise. */ +static int signer_mode_is_remote_local(const char* mode) { + if (!mode || mode[0] == '\0') { + return 0; + } + return strcmp(mode, "nsigner_unix") == 0 || + strcmp(mode, "nsigner_tcp") == 0; +} + +/* Apply CLI/env signer overrides onto cfg->signer. + * Precedence: CLI flag > env var > genesis signer block > default. + * Returns 0 on success, -1 on invalid override (with stderr message). */ +static int apply_signer_overrides(didactyl_config_t* cfg, + const char* cli_mode, + const char* cli_socket, + const char* cli_role, + int cli_timeout, + const char* cli_tcp) { + if (!cfg) { + return -1; + } + + /* --signer-tcp shorthand: forces mode=nsigner_tcp and parses host:port. */ + if (cli_tcp && cli_tcp[0] != '\0') { + if (parse_host_port(cli_tcp, cfg->signer.tcp_host, + sizeof(cfg->signer.tcp_host), + &cfg->signer.tcp_port) != 0) { + fprintf(stderr, "Invalid --signer-tcp value (expected host:port, got '%s')\n", cli_tcp); + return -1; + } + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_tcp"); + } else if (cli_mode && cli_mode[0] != '\0') { + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", cli_mode); + } else { + const char* env_signer = getenv("DIDACTYL_SIGNER"); + if (env_signer && env_signer[0] != '\0') { + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", env_signer); + } + } + + /* Validate the final mode. */ + const char* mode = cfg->signer.mode; + if (mode[0] == '\0' || + (strcmp(mode, "local") != 0 && + strcmp(mode, "nsigner_unix") != 0 && + strcmp(mode, "nsigner_tcp") != 0)) { + fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, nsigner_tcp)\n", + mode[0] ? mode : ""); + return -1; + } + + if (cli_socket && cli_socket[0] != '\0') { + snprintf(cfg->signer.socket_name, sizeof(cfg->signer.socket_name), "%s", cli_socket); + } + if (cli_role && cli_role[0] != '\0') { + snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", cli_role); + } + if (cli_timeout > 0) { + cfg->signer.timeout_ms = cli_timeout; + } + + return 0; +} + +/* Construct the process-lifetime nostr_signer_t* from cfg->signer. + * In local mode uses cfg->keys.private_key (existing behavior). + * In remote modes delegates to the nsigner client. + * Runs nostr_signer_get_public_key as a connectivity check and populates + * cfg->keys.public_key/public_key_hex when remote. + * Returns the signer handle on success, NULL on failure (with stderr message). */ +static nostr_signer_t* construct_signer(didactyl_config_t* cfg) { + if (!cfg) { + return NULL; + } + + const char* mode = cfg->signer.mode; + nostr_signer_t* signer = NULL; + + if (strcmp(mode, "local") == 0) { + signer = nostr_signer_local(cfg->keys.private_key); + if (!signer) { + fprintf(stderr, "Failed to construct local signer (mode=local): " + "nostr_signer_local returned NULL\n"); + signer_health_set_mode("local"); + signer_health_record_failure(NOSTR_ERROR_CRYPTO_FAILED, + "nostr_signer_local returned NULL"); + return NULL; + } + /* Pubkey already derived from nsec in config_load/derive_keys path. */ + signer_health_set_mode("local"); + signer_health_record_ok(); + return signer; + } + +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + if (strcmp(mode, "nsigner_unix") == 0) { + const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL; + signer = nostr_signer_nsigner_unix(sock, cfg->signer.role, cfg->signer.timeout_ms); + } else if (strcmp(mode, "nsigner_tcp") == 0) { + if (cfg->signer.tcp_host[0] == '\0' || cfg->signer.tcp_port <= 0) { + fprintf(stderr, "nsigner_tcp mode requires --signer-tcp or " + "signer.tcp_host/tcp_port in config (mode=nsigner_tcp)\n"); + signer_health_set_mode("nsigner_tcp"); + signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, + "missing tcp_host/tcp_port"); + return NULL; + } + signer = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port, + cfg->signer.role, cfg->signer.timeout_ms); + if (signer && cfg->signer.auth_privkey_hex[0] != '\0') { + unsigned char auth_key[32]; + if (nostr_hex_to_bytes(cfg->signer.auth_privkey_hex, auth_key, 32) == 0) { + if (nostr_signer_nsigner_set_auth(signer, auth_key, "didactyl") != NOSTR_SUCCESS) { + fprintf(stderr, "Warning: failed to set nsigner TCP auth envelope " + "(mode=nsigner_tcp, host=%s:%d)\n", + cfg->signer.tcp_host, cfg->signer.tcp_port); + } + } else { + fprintf(stderr, "Warning: signer.auth_privkey_hex is not valid 64-char hex; " + "TCP auth not set (mode=nsigner_tcp, host=%s:%d)\n", + cfg->signer.tcp_host, cfg->signer.tcp_port); + } + } + } else { + fprintf(stderr, "Unknown signer mode '%s' (expected: local, nsigner_unix, nsigner_tcp)\n", mode); + signer_health_set_mode(mode); + signer_health_record_failure(NOSTR_ERROR_INVALID_INPUT, "unknown signer mode"); + return NULL; + } + + signer_health_set_mode(mode); + if (!signer) { + const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; + fprintf(stderr, "Failed to construct remote signer (mode=%s, socket=%s, role=%s, " + "timeout_ms=%d): nostr_signer_nsigner_* returned NULL\n", + mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms); + signer_health_record_failure(NOSTR_ERROR_IO_FAILED, "signer construction returned NULL"); + return NULL; + } + + /* Connectivity check + pubkey discovery for remote modes. */ + char pubkey_hex[65] = {0}; + int gpk_rc = nostr_signer_get_public_key(signer, pubkey_hex); + if (gpk_rc != NOSTR_SUCCESS) { + const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""; + fprintf(stderr, "Signer connectivity check failed (mode=%s, socket=%s, role=%s, " + "timeout_ms=%d, rc=%d): could not retrieve public key from n_signer. " + "Is the n_signer process running and reachable?\n", + mode, strcmp(mode, "nsigner_unix") == 0 ? sock : "", + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms, gpk_rc); + signer_health_record_failure(gpk_rc, "startup connectivity check (get_public_key)"); + nostr_signer_free(signer); + return NULL; + } + + /* Populate cfg.keys.public_key/public_key_hex from the signer. */ + if (cfg->keys.public_key_hex[0] == '\0') { + snprintf(cfg->keys.public_key_hex, sizeof(cfg->keys.public_key_hex), "%s", pubkey_hex); + if (nostr_hex_to_bytes(pubkey_hex, cfg->keys.public_key, 32) != 0) { + fprintf(stderr, "Warning: signer returned non-hex pubkey '%s' (mode=%s)\n", + pubkey_hex, mode); + } + } + signer_health_record_ok(); + DEBUG_INFO("[didactyl] signer connected (mode=%s, agent pubkey=%.16s...)", mode, pubkey_hex); + return signer; +#else + fprintf(stderr, "Signer mode '%s' requires nsigner client support, which is not compiled in\n", mode); + return NULL; +#endif +} + static int derive_keys_from_nsec(const char* nsec_or_hex, didactyl_config_t* cfg) { if (!nsec_or_hex || !cfg) return -1; @@ -877,7 +1099,10 @@ static int decode_pubkey_hex_or_npub_local(const char* input, char out_hex[65]) return -1; } -static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char* d_tag, char** out_plaintext) { +static int fetch_self_config_plaintext(const didactyl_config_t* cfg, + nostr_signer_t* signer, + const char* d_tag, + char** out_plaintext) { if (!cfg || !d_tag || !out_plaintext || cfg->keys.public_key_hex[0] == '\0') { return -1; } @@ -943,8 +1168,26 @@ static int fetch_self_config_plaintext(const didactyl_config_t* cfg, const char* return -1; } + /* Phase 3 item 13: route self-decrypt through the process signer when + * available so remote signer modes keep the nsec out of the agent. The + * signer verb returns a heap string via out-param. Falls back to the + * legacy raw-key path when no signer is passed. */ + char* plaintext = NULL; + if (signer) { + int dec_rc = nostr_signer_nip44_decrypt(signer, + cfg->keys.public_key_hex, + content->valuestring, + &plaintext); + cJSON_Delete(arr); + if (dec_rc != NOSTR_SUCCESS || !plaintext) { + return -1; + } + *out_plaintext = plaintext; + return 0; + } + size_t out_cap = strlen(content->valuestring) + 1024U; - char* plaintext = (char*)malloc(out_cap); + plaintext = (char*)malloc(out_cap); if (!plaintext) { cJSON_Delete(arr); return -1; @@ -1053,21 +1296,37 @@ static int apply_recalled_user_settings(didactyl_config_t* cfg, return 0; } -static int publish_encrypted_self_config(const didactyl_config_t* cfg, const char* d_tag, const char* content_json) { +static int publish_encrypted_self_config(const didactyl_config_t* cfg, + nostr_signer_t* signer, + const char* d_tag, + const char* content_json) { if (!cfg || !d_tag || !content_json || d_tag[0] == '\0') return -1; - size_t cipher_cap = (strlen(content_json) * 4U) + 1024U; - char* ciphertext = (char*)malloc(cipher_cap); - if (!ciphertext) return -1; + /* Phase 3 item 13: route self-encrypt through the process signer when + * available. Falls back to the legacy raw-key path when no signer. */ + char* ciphertext = NULL; + if (signer) { + int enc_rc = nostr_signer_nip44_encrypt(signer, + cfg->keys.public_key_hex, + content_json, + &ciphertext); + if (enc_rc != NOSTR_SUCCESS || !ciphertext) { + return -1; + } + } else { + size_t cipher_cap = (strlen(content_json) * 4U) + 1024U; + ciphertext = (char*)malloc(cipher_cap); + if (!ciphertext) return -1; - int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key, - cfg->keys.public_key, - content_json, - ciphertext, - cipher_cap); - if (enc_rc != NOSTR_SUCCESS) { - free(ciphertext); - return -1; + int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key, + cfg->keys.public_key, + content_json, + ciphertext, + cipher_cap); + if (enc_rc != NOSTR_SUCCESS) { + free(ciphertext); + return -1; + } } cJSON* tags = cJSON_CreateArray(); @@ -1098,7 +1357,9 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha return rc; } -static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) { +static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, + nostr_signer_t* signer, + int persist_llm_config) { if (!cfg) return -1; if (!persist_llm_config) { @@ -1138,12 +1399,14 @@ static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int per return -1; } - int rc = publish_encrypted_self_config(cfg, "user-settings", user_settings_json); + int rc = publish_encrypted_self_config(cfg, signer, "user-settings", user_settings_json); free(user_settings_json); return rc; } -static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg, int* out_backfill_needed) { +static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg, + nostr_signer_t* signer, + int* out_backfill_needed) { if (!cfg) { return 0; } @@ -1152,7 +1415,7 @@ static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg, int } char* settings_plaintext = NULL; - if (fetch_self_config_plaintext(cfg, "user-settings", &settings_plaintext) != 0 || !settings_plaintext) { + if (fetch_self_config_plaintext(cfg, signer, "user-settings", &settings_plaintext) != 0 || !settings_plaintext) { DEBUG_WARN("[didactyl] startup phase: user-settings recall unavailable"); free(settings_plaintext); return 0; @@ -1187,8 +1450,15 @@ int main(int argc, char** argv) { int dump_schemas = 0; const char* test_tool_name = NULL; const char* test_tool_args = "{}"; + /* Signer CLI overrides (NULL/0 = not provided, use config/env/default). */ + const char* cli_signer_mode = NULL; + const char* cli_signer_socket = NULL; + const char* cli_signer_role = NULL; + int cli_signer_timeout = 0; + const char* cli_signer_tcp = NULL; /* host:port shorthand */ didactyl_config_t cfg; memset(&cfg, 0, sizeof(cfg)); + nostr_signer_t* g_signer = NULL; int config_preloaded = 0; int bootstrap_mode = 0; @@ -1250,11 +1520,32 @@ int main(int argc, char** argv) { api_port_override = atoi(argv[++i]); } else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) { api_bind_override = argv[++i]; + } else if (strcmp(argv[i], "--signer") == 0 && i + 1 < argc) { + cli_signer_mode = argv[++i]; + } else if (strcmp(argv[i], "--signer-socket") == 0 && i + 1 < argc) { + cli_signer_socket = argv[++i]; + } else if (strcmp(argv[i], "--signer-role") == 0 && i + 1 < argc) { + cli_signer_role = argv[++i]; + } else if (strcmp(argv[i], "--signer-timeout") == 0 && i + 1 < argc) { + cli_signer_timeout = atoi(argv[++i]); + } else if (strcmp(argv[i], "--signer-tcp") == 0 && i + 1 < argc) { + cli_signer_tcp = argv[++i]; } else if (strcmp(argv[i], "--dump-schemas") == 0) { dump_schemas = 1; } else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) { test_tool_name = argv[++i]; test_tool_args = argv[++i]; + } else if (strcmp(argv[i], "--test-tool") == 0) { + /* --test-tool requires exactly two arguments: . + * Without them the generic "unknown arg" usage dump is misleading + * (it looks like a startup/signer failure when the real issue is + * a missing args_json operand). Emit a precise error instead. */ + if (i + 1 >= argc) { + fprintf(stderr, "--test-tool requires (missing both)\n"); + } else { + fprintf(stderr, "--test-tool requires (missing args_json after '%s')\n", argv[i + 1]); + } + return 1; } else { print_usage(argv[0]); return 1; @@ -1283,21 +1574,39 @@ int main(int argc, char** argv) { } } - if (cfg.keys.nsec[0] == '\0') { - const char* env_nsec = getenv("DIDACTYL_NSEC"); - const char* input_nsec = (cli_nsec && cli_nsec[0] != '\0') ? cli_nsec : env_nsec; - if (!input_nsec || input_nsec[0] == '\0') { - fprintf(stderr, "No nsec available: provide key.nsec in config, --nsec, or DIDACTYL_NSEC\n"); - config_free(&cfg); - nostr_cleanup(); - return 1; - } - if (derive_keys_from_nsec(input_nsec, &cfg) != 0) { - fprintf(stderr, "Invalid nsec provided via --nsec or DIDACTYL_NSEC\n"); - config_free(&cfg); - nostr_cleanup(); - return 1; + /* Apply signer CLI/env overrides (precedence: CLI > env > config > default). + * Done before the nsec-required check so remote modes can skip the nsec. */ + if (apply_signer_overrides(&cfg, cli_signer_mode, cli_signer_socket, + cli_signer_role, cli_signer_timeout, + cli_signer_tcp) != 0) { + config_free(&cfg); + nostr_cleanup(); + return 1; + } + + /* In local mode an nsec is required (existing behavior). In remote + * (nsigner_*) modes the nsec is optional and ignored; the pubkey is + * learned from the signer. */ + if (!signer_mode_is_remote_local(cfg.signer.mode)) { + if (cfg.keys.nsec[0] == '\0') { + const char* env_nsec = getenv("DIDACTYL_NSEC"); + const char* input_nsec = (cli_nsec && cli_nsec[0] != '\0') ? cli_nsec : env_nsec; + if (!input_nsec || input_nsec[0] == '\0') { + fprintf(stderr, "No nsec available: provide key.nsec in config, --nsec, or DIDACTYL_NSEC\n"); + config_free(&cfg); + nostr_cleanup(); + return 1; + } + if (derive_keys_from_nsec(input_nsec, &cfg) != 0) { + fprintf(stderr, "Invalid nsec provided via --nsec or DIDACTYL_NSEC\n"); + config_free(&cfg); + nostr_cleanup(); + return 1; + } } + } else { + DEBUG_INFO("[didactyl] signer mode '%s': nsec not required, agent pubkey will be discovered from signer", + cfg.signer.mode); } if (cli_admin_pubkey && cli_admin_pubkey[0] != '\0') { @@ -1312,13 +1621,6 @@ int main(int argc, char** argv) { DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey); } - if (config_ensure_startup_skill_adoption(&cfg) != 0) { - fprintf(stderr, "Failed to synthesize startup skill adoption events\n"); - config_free(&cfg); - nostr_cleanup(); - return 1; - } - if (api_port_override > 0 && api_port_override <= 65535) { cfg.api.enabled = 1; cfg.api.port = api_port_override; @@ -1335,6 +1637,34 @@ int main(int argc, char** argv) { } } + /* Construct the process-lifetime signer handle. In local mode this wraps + * cfg.keys.private_key (output-equivalent to the legacy raw-key path). + * In remote modes this performs the mandatory startup connectivity check + * and populates cfg.keys.public_key/public_key_hex from the signer. + * + * This must run before config_ensure_startup_skill_adoption() so the + * agent pubkey is known (from the signer in remote mode) before skill + * adoption events are synthesized. */ + g_signer = construct_signer(&cfg); + if (!g_signer) { + config_free(&cfg); + nostr_cleanup(); + return 1; + } + + /* Wire the signer into the nostr handler so relay pool AUTH is routed + * through it. In local mode this is output-equivalent to the legacy + * raw-key path. The handler does not own the signer. */ + nostr_handler_set_signer(g_signer); + + if (config_ensure_startup_skill_adoption(&cfg) != 0) { + fprintf(stderr, "Failed to synthesize startup skill adoption events\n"); + nostr_signer_free(g_signer); + config_free(&cfg); + nostr_cleanup(); + return 1; + } + if (dump_schemas || test_tool_name) { if (llm_init(&cfg.llm) != 0) { fprintf(stderr, "Failed to initialize llm client\n"); @@ -1352,6 +1682,7 @@ int main(int argc, char** argv) { } (void)nostr_block_list_init(&cfg); + nostr_block_list_set_signer(g_signer); (void)nostr_block_list_load_from_relays(); tools_context_t tools_ctx; @@ -1364,10 +1695,12 @@ int main(int argc, char** argv) { nostr_cleanup(); return 1; } + tools_set_signer(&tools_ctx, g_signer); int wallet_initialized = 0; int exit_code = 0; + cashu_wallet_set_signer(g_signer); if (cashu_wallet_init(&cfg) == 0) { wallet_initialized = 1; if (cfg.cashu_wallet.enabled) { @@ -1428,6 +1761,7 @@ int main(int argc, char** argv) { nostr_handler_cleanup(); config_free(&cfg); llm_cleanup(); + nostr_signer_free(g_signer); nostr_cleanup(); return exit_code; } @@ -1440,6 +1774,7 @@ int main(int argc, char** argv) { } (void)nostr_block_list_init(&cfg); + nostr_block_list_set_signer(g_signer); (void)nostr_block_list_load_from_relays(); const int startup_connect_timeout_ms = 15000; @@ -1455,7 +1790,7 @@ int main(int argc, char** argv) { startup_step_begin(2, "Recover runtime config from Nostr"); int user_settings_backfill_needed = 0; - int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg, &user_settings_backfill_needed); + int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg, g_signer, &user_settings_backfill_needed); startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr @@ -1533,6 +1868,7 @@ int main(int argc, char** argv) { if (bootstrap_mode || first_run || user_settings_backfill_needed) { if (persist_runtime_config_to_nostr(&cfg, + g_signer, (llm_recalled_from_nostr && !user_settings_backfill_needed) ? 0 : 1) != 0) { DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr"); } else { @@ -1556,6 +1892,7 @@ int main(int argc, char** argv) { nostr_cleanup(); return 1; } + agent_set_signer(g_signer); startup_step_ok(8, "Initialize agent", NULL); startup_step_begin(9, "Initialize trigger manager"); @@ -1826,6 +2163,7 @@ int main(int argc, char** argv) { startup_step_ok(16, "Subscribe wallet events", NULL); startup_step_begin(17, "Initialize cashu wallet"); + cashu_wallet_set_signer(g_signer); if (cashu_wallet_init(&cfg) != 0) { DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)"); startup_step_fail(17, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors"); @@ -1955,6 +2293,7 @@ int main(int argc, char** argv) { nostr_handler_cleanup(); llm_cleanup(); config_free(&cfg); + nostr_signer_free(g_signer); nostr_cleanup(); return 0; diff --git a/src/main.h b/src/main.h index 13115ff..9bb0f76 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 2 -#define DIDACTYL_VERSION_PATCH 50 -#define DIDACTYL_VERSION "v0.2.50" +#define DIDACTYL_VERSION_PATCH 51 +#define DIDACTYL_VERSION "v0.2.51" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_block_list.c b/src/nostr_block_list.c index 46c3a4d..c68bbd4 100644 --- a/src/nostr_block_list.c +++ b/src/nostr_block_list.c @@ -10,6 +10,10 @@ #include "debug.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" +/* Optional process-lifetime signer for NIP-44 encrypt/decrypt of private + * block-list content. Owned by main.c; non-owning here. */ +static nostr_signer_t* g_block_signer = NULL; + typedef struct { didactyl_config_t* cfg; int initialized; @@ -186,22 +190,43 @@ static int decrypt_private_tags_array(const char* encrypted_content, cJSON** out return *out_array ? 0 : -1; } - size_t cap = strlen(encrypted_content) + 4096; - char* plaintext = (char*)calloc(1, cap); - if (!plaintext) return -1; + /* Phase 3 item 13: route NIP-44 decrypt through the process signer when + * available so remote signer modes keep the nsec out of the agent. The + * signer verb returns a heap string via out-param; free it after parse. + * Falls back to the legacy raw-key path when no signer is set. */ + const char* plain_src = NULL; + char* plain_to_free = NULL; + char* plaintext = NULL; - int rc = nostr_nip44_decrypt(g_block.cfg->keys.private_key, - g_block.cfg->keys.public_key, - encrypted_content, - plaintext, - cap); - if (rc != 0) { - free(plaintext); - return -1; + if (g_block_signer) { + int rc = nostr_signer_nip44_decrypt(g_block_signer, + g_block.cfg->keys.public_key_hex, + encrypted_content, + &plaintext); + if (rc != NOSTR_SUCCESS || !plaintext) { + return -1; + } + plain_src = plaintext; + plain_to_free = plaintext; + } else { + size_t cap = strlen(encrypted_content) + 4096; + plaintext = (char*)calloc(1, cap); + if (!plaintext) return -1; + int rc = nostr_nip44_decrypt(g_block.cfg->keys.private_key, + g_block.cfg->keys.public_key, + encrypted_content, + plaintext, + cap); + if (rc != 0) { + free(plaintext); + return -1; + } + plain_src = plaintext; + plain_to_free = plaintext; } - cJSON* arr = cJSON_Parse(plaintext); - free(plaintext); + cJSON* arr = cJSON_Parse(plain_src); + free(plain_to_free); if (!arr || !cJSON_IsArray(arr)) { cJSON_Delete(arr); return -1; @@ -218,8 +243,25 @@ static int encrypt_private_tags_array(cJSON* private_tags_array, char** out_cont char* plain = cJSON_PrintUnformatted(private_tags_array); if (!plain) return -1; + /* Phase 3 item 13: route NIP-44 encrypt through the process signer when + * available. The signer verb returns a heap string via out-param. Falls + * back to the legacy raw-key path when no signer is set. */ + char* cipher = NULL; + if (g_block_signer) { + int rc = nostr_signer_nip44_encrypt(g_block_signer, + g_block.cfg->keys.public_key_hex, + plain, + &cipher); + free(plain); + if (rc != NOSTR_SUCCESS || !cipher) { + return -1; + } + *out_content = cipher; + return 0; + } + size_t cap = strlen(plain) * 4 + 4096; - char* cipher = (char*)calloc(1, cap); + cipher = (char*)calloc(1, cap); if (!cipher) { free(plain); return -1; @@ -311,6 +353,12 @@ static int publish_kind10000(cJSON* public_tags, cJSON* private_tags, char* out_ return 0; } +void nostr_block_list_set_signer(nostr_signer_t* signer) { + pthread_mutex_lock(&g_block.mutex); + g_block_signer = signer; + pthread_mutex_unlock(&g_block.mutex); +} + int nostr_block_list_init(didactyl_config_t* cfg) { if (!cfg) return -1; @@ -365,6 +413,7 @@ void nostr_block_list_cleanup(void) { g_block.cfg = NULL; g_block.loaded = 0; g_block.initialized = 0; + g_block_signer = NULL; pthread_mutex_unlock(&g_block.mutex); } diff --git a/src/nostr_block_list.h b/src/nostr_block_list.h index ec993d2..ae71a81 100644 --- a/src/nostr_block_list.h +++ b/src/nostr_block_list.h @@ -3,11 +3,19 @@ #include "config.h" #include "cjson/cJSON.h" +#include "../nostr_core_lib/nostr_core/nostr_signer.h" int nostr_block_list_init(didactyl_config_t* cfg); int nostr_block_list_load_from_relays(void); void nostr_block_list_cleanup(void); +/* Wire a process-lifetime signer handle (non-owning) so NIP-44 encrypt/decrypt + * of the private block-list content routes through the signer verbs in remote + * signer modes. Falls back to the legacy raw-key path when NULL. The caller + * must keep the signer alive for the module's lifetime and free it after + * nostr_block_list_cleanup. */ +void nostr_block_list_set_signer(nostr_signer_t* signer); + int nostr_block_list_is_pubkey_blocked(const char* pubkey_hex); int nostr_block_list_is_event_blocked(const char* event_id_hex); int nostr_block_list_is_hashtag_blocked(const char* hashtag); diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 1c5c43e..06c6faa 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -15,6 +15,7 @@ #include "nostr_block_list.h" #include "cashu_wallet.h" #include "agent.h" +#include "signer_health.h" /* * Older nostr_core_lib versions only expose: @@ -34,6 +35,8 @@ static didactyl_config_t* g_cfg = NULL; static nostr_relay_pool_t* g_pool = NULL; +/* Optional process-lifetime signer for relay pool AUTH. Owned by main.c. */ +static nostr_signer_t* g_auth_signer = NULL; static dm_callback_t g_dm_callback = NULL; static void* g_dm_user_data = NULL; static int g_poll_counter = 0; @@ -1048,6 +1051,26 @@ static int nip44_encrypt_self_local(const char* plaintext, char** out_ciphertext } const char* plain = plaintext ? plaintext : ""; + + /* Phase 3 item 13: route self-encrypt through the process signer when + * available so remote signer modes keep the nsec out of the agent. The + * signer verb returns a heap string via out-param. Falls back to the + * legacy raw-key path when no signer is set (local mode / pre-Phase-3). */ + if (g_auth_signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_encrypt(g_auth_signer, + g_cfg->keys.public_key_hex, + plain, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) { + signer_health_record_failure(rc, "nip44_encrypt(self)"); + return -1; + } + signer_health_record_ok(); + *out_ciphertext = signer_out; + return 0; + } + size_t out_cap = (strlen(plain) * 4U) + 1024U; char* ciphertext = (char*)malloc(out_cap); if (!ciphertext) { @@ -1073,6 +1096,23 @@ static int nip44_decrypt_self_local(const char* ciphertext, char** out_plaintext return -1; } + /* Phase 3 item 13: route self-decrypt through the process signer when + * available. Falls back to the legacy raw-key path when no signer is set. */ + if (g_auth_signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_decrypt(g_auth_signer, + g_cfg->keys.public_key_hex, + ciphertext, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) { + signer_health_record_failure(rc, "nip44_decrypt(self)"); + return -1; + } + signer_health_record_ok(); + *out_plaintext = signer_out; + return 0; + } + size_t out_cap = strlen(ciphertext) + 1024U; char* plaintext = (char*)malloc(out_cap); if (!plaintext) { @@ -1918,19 +1958,38 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } - decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE); - if (!decrypted) { - fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n"); - return; - } - decrypted[0] = '\0'; - trace_event_json("received encrypted DM event:", event); - if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) { - fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex); - free(decrypted); - return; + /* Phase 3 item 13: route NIP-04 decrypt through the process signer + * when available so remote signer modes keep the nsec out of the + * agent. The signer verb returns a heap string via out-param. Falls + * back to the legacy raw-key path when no signer is set. */ + if (g_auth_signer) { + char* signer_out = NULL; + int dec_rc = nostr_signer_nip04_decrypt(g_auth_signer, + sender_pubkey_hex, + content->valuestring, + &signer_out); + if (dec_rc != NOSTR_SUCCESS || !signer_out) { + signer_health_record_failure(dec_rc, "nip04_decrypt(incoming DM)"); + fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex); + return; + } + signer_health_record_ok(); + decrypted = signer_out; + } else { + decrypted = (char*)malloc(NOSTR_NIP04_MAX_PLAINTEXT_SIZE); + if (!decrypted) { + fprintf(stderr, "[didactyl] failed to allocate DM decrypt buffer\n"); + return; + } + decrypted[0] = '\0'; + + if (nostr_nip04_decrypt(g_cfg->keys.private_key, sender_pubkey, content->valuestring, decrypted, NOSTR_NIP04_MAX_PLAINTEXT_SIZE) != NOSTR_SUCCESS) { + fprintf(stdout, "[didactyl] failed to decrypt incoming DM from %.16s...\n", sender_pubkey_hex); + free(decrypted); + return; + } } trace_plaintext_dm("received decrypted DM content:", decrypted); @@ -1941,7 +2000,16 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { return; } - cJSON* rumor = nostr_nip17_receive_dm(event, g_cfg->keys.private_key); + /* Phase 3 item 14: prefer signer-aware NIP-17 unwrap when a process + * signer is available; fall back to the legacy raw-key path in local + * mode / pre-Phase-3. Gift-wrap event ownership is unchanged: the + * returned rumor cJSON is owned by us and freed below. */ + cJSON* rumor = NULL; + if (g_auth_signer) { + rumor = nostr_nip17_receive_dm_with_signer(event, g_auth_signer); + } else { + rumor = nostr_nip17_receive_dm(event, g_cfg->keys.private_key); + } if (!rumor) { DEBUG_TRACE("[didactyl] DEBUG on_event: failed to unwrap/decrypt NIP-17 gift wrap %.16s...", event_id_hex ? event_id_hex : ""); @@ -2520,6 +2588,10 @@ static void on_wallet_event(cJSON* event, const char* relay_url, void* user_data (void)cashu_wallet_load_from_relays(); } +void nostr_handler_set_signer(nostr_signer_t* signer) { + g_auth_signer = signer; +} + int nostr_handler_init(didactyl_config_t* config) { if (!config) { return -1; @@ -2569,9 +2641,21 @@ int nostr_handler_init(didactyl_config_t* config) { return -1; } - if (nostr_relay_pool_set_auth(g_pool, g_cfg->keys.private_key, 1) != NOSTR_SUCCESS) { - fprintf(stderr, "[didactyl] failed to enable relay pool auth\n"); - return -1; + /* Relay pool NIP-42 AUTH. When a process-lifetime signer is available + * (set via nostr_handler_set_signer), route AUTH signing through it so + * remote signer modes can keep the nsec out of the agent process. In + * local mode the signer wraps the same raw key, so behavior is identical. + * Falls back to the legacy raw-key path when no signer is set. */ + if (g_auth_signer) { + if (nostr_relay_pool_set_auth_with_signer(g_pool, g_auth_signer, 1) != NOSTR_SUCCESS) { + fprintf(stderr, "[didactyl] failed to enable relay pool auth (signer path)\n"); + return -1; + } + } else { + if (nostr_relay_pool_set_auth(g_pool, g_cfg->keys.private_key, 1) != NOSTR_SUCCESS) { + fprintf(stderr, "[didactyl] failed to enable relay pool auth\n"); + return -1; + } } for (int i = 0; i < g_cfg->relay_count; i++) { @@ -3144,16 +3228,33 @@ int nostr_handler_send_dm_with_role(const char* recipient_pubkey_hex, trace_plaintext_dm("sending plaintext DM content:", message); - char* encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE); - if (!encrypted) { - fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n"); - return -1; - } - encrypted[0] = '\0'; + /* Phase 3 item 13: route NIP-04 encrypt through the process signer when + * available so remote signer modes keep the nsec out of the agent. The + * signer verb returns a heap string via out-param. Falls back to the + * legacy raw-key path when no signer is set. */ + char* encrypted = NULL; + if (g_auth_signer) { + int enc_rc = nostr_signer_nip04_encrypt(g_auth_signer, + recipient_pubkey_hex, + message, + &encrypted); + if (enc_rc != NOSTR_SUCCESS || !encrypted) { + signer_health_record_failure(enc_rc, "nip04_encrypt(outgoing DM)"); + return -1; + } + signer_health_record_ok(); + } else { + encrypted = (char*)malloc(NOSTR_NIP04_MAX_ENCRYPTED_SIZE); + if (!encrypted) { + fprintf(stderr, "[didactyl] failed to allocate DM encrypt buffer\n"); + return -1; + } + encrypted[0] = '\0'; - if (nostr_nip04_encrypt(g_cfg->keys.private_key, recipient_pubkey, message, encrypted, NOSTR_NIP04_MAX_ENCRYPTED_SIZE) != NOSTR_SUCCESS) { - free(encrypted); - return -1; + if (nostr_nip04_encrypt(g_cfg->keys.private_key, recipient_pubkey, message, encrypted, NOSTR_NIP04_MAX_ENCRYPTED_SIZE) != NOSTR_SUCCESS) { + free(encrypted); + return -1; + } } cJSON* tags = create_dm_tags_for_recipient(recipient_pubkey_hex); @@ -3162,7 +3263,17 @@ int nostr_handler_send_dm_with_role(const char* recipient_pubkey_hex, return -1; } - cJSON* event = nostr_create_and_sign_event(4, encrypted, tags, g_cfg->keys.private_key, time(NULL)); + /* Phase 3 item 12: route kind-4 DM event signing through the process + * signer handle when available so remote signer modes can keep the nsec + * out of the agent process. Falls back to the legacy raw-key path when + * no signer is set (local mode / pre-Phase-2 binaries) so behavior is + * unchanged. */ + cJSON* event = NULL; + if (g_auth_signer) { + event = nostr_create_and_sign_event_with_signer(4, encrypted, tags, g_auth_signer, time(NULL)); + } else { + event = nostr_create_and_sign_event(4, encrypted, tags, g_cfg->keys.private_key, time(NULL)); + } cJSON_Delete(tags); free(encrypted); if (!event) { @@ -3301,7 +3412,17 @@ static int publish_kind_event_to_relays(int kind, } } - cJSON* event = nostr_create_and_sign_event(kind, content_to_publish, tags_copy, g_cfg->keys.private_key, time(NULL)); + /* Phase 3 item 12: route generic kind event signing through the process + * signer handle when available (covers kind 30078 config publish, kind + * 31124 private skill events, startup adoption events, etc.). Falls back + * to the legacy raw-key path when no signer is set so local mode is + * unchanged. */ + cJSON* event = NULL; + if (g_auth_signer) { + event = nostr_create_and_sign_event_with_signer(kind, content_to_publish, tags_copy, g_auth_signer, time(NULL)); + } else { + event = nostr_create_and_sign_event(kind, content_to_publish, tags_copy, g_cfg->keys.private_key, time(NULL)); + } if (tags_copy) { cJSON_Delete(tags_copy); } @@ -4359,14 +4480,29 @@ int nostr_handler_send_dm_nip17_with_role(const char* recipient_pubkey_hex, return -1; } + /* Phase 3 item 14: prefer signer-aware NIP-17 gift-wrap creation when a + * process signer is available; fall back to the legacy raw-key path in + * local mode / pre-Phase-3. Gift-wrap ownership is unchanged: each + * non-NULL entry in gift_wraps[] is freed by cJSON_Delete below. */ cJSON* gift_wraps[NIP17_MAX_GIFT_WRAPS] = {0}; - int gift_count = nostr_nip17_send_dm(chat_event, + int gift_count = 0; + if (g_auth_signer) { + gift_count = nostr_nip17_send_dm_with_signer(chat_event, + recipients, + 1, + g_auth_signer, + gift_wraps, + NIP17_MAX_GIFT_WRAPS, + 0); + } else { + gift_count = nostr_nip17_send_dm(chat_event, recipients, 1, g_cfg->keys.private_key, gift_wraps, NIP17_MAX_GIFT_WRAPS, 0); + } cJSON_Delete(chat_event); if (gift_count <= 0) { DEBUG_WARN("[didactyl] NIP-17 send aborted: gift_wrap creation failed for %.16s...", @@ -4930,6 +5066,7 @@ void nostr_handler_cleanup(void) { g_pool = NULL; g_cfg = NULL; + g_auth_signer = NULL; g_dm_callback = NULL; g_dm_user_data = NULL; memset(g_seen_dm_ids, 0, sizeof(g_seen_dm_ids)); diff --git a/src/nostr_handler.h b/src/nostr_handler.h index 2a5dbe8..40dfb8d 100644 --- a/src/nostr_handler.h +++ b/src/nostr_handler.h @@ -40,6 +40,15 @@ typedef struct { typedef void (*nostr_self_skill_eose_cb_t)(int event_count, void* user_data); int nostr_handler_init(didactyl_config_t* config); + +/* Optional: provide a process-lifetime signer handle for relay pool AUTH. + * When set, nostr_handler_init routes NIP-42 AUTH through the signer via + * nostr_relay_pool_set_auth_with_signer instead of the raw private-key path. + * The caller retains ownership of the signer and must free it after + * nostr_handler_cleanup. If not called, the legacy raw-key AUTH path is used + * (backward compatible). */ +void nostr_handler_set_signer(nostr_signer_t* signer); + void nostr_handler_set_trigger_manager(struct trigger_manager* trigger_manager); int nostr_handler_subscribe_admin_context(void); int nostr_handler_subscribe_agent_context(void); diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 907a42f..6257d4d 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -316,6 +316,13 @@ static void config_set_defaults(didactyl_config_t* cfg) { snprintf(cfg->llm.base_url, sizeof(cfg->llm.base_url), "%s", "https://api.openai.com/v1"); cfg->llm.max_tokens = 512; cfg->llm.temperature = 0.7; + + /* Signer provider defaults: local mode, backward compatible. + * Without these, apply_signer_overrides() in main.c rejects an empty + * signer.mode after the wizard returns BOOTSTRAP for local paths. */ + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "local"); + snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", "main"); + cfg->signer.timeout_ms = 15000; } static void free_relays_only(didactyl_config_t* cfg) { @@ -994,6 +1001,15 @@ static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, co return -1; } + /* Phase 3 item 13: DEFERRED — the setup wizard runs before any + * process-lifetime signer is constructed. In local mode (the only mode + * the wizard persists keys for) the nsec is held directly by the wizard, + * so the raw-key path is correct and output-equivalent. The nsigner_unix + * wizard path only does an ephemeral connectivity check and frees its + * signer immediately; it does not recall/persist encrypted user-settings. + * Migrating this site would require threading an ephemeral signer through + * the wizard for no separation benefit, so it is intentionally left on + * the legacy raw-key path. */ int dec_rc = nostr_nip44_decrypt(cfg->keys.private_key, cfg->keys.public_key, content->valuestring, @@ -1141,6 +1157,10 @@ static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg, char* ciphertext = (char*)malloc(cipher_cap); if (!ciphertext) return -1; + /* Phase 3 item 13: DEFERRED — see the matching note in the wizard's + * fetch_self_config_plaintext. The wizard holds the nsec directly in + * local mode and has no process-lifetime signer, so the raw-key path is + * correct here. */ int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key, cfg->keys.public_key, content_json, @@ -1208,6 +1228,15 @@ static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) cJSON_AddNumberToObject(didactyl, "max_turns", cfg->tools.max_turns > 0 ? cfg->tools.max_turns : 40); cJSON_AddItemToObject(user_settings, "didactyl", didactyl); + cJSON* signer = cJSON_CreateObject(); + if (signer) { + cJSON_AddStringToObject(signer, "mode", cfg->signer.mode); + cJSON_AddStringToObject(signer, "socket_name", cfg->signer.socket_name); + cJSON_AddStringToObject(signer, "role", cfg->signer.role); + cJSON_AddNumberToObject(signer, "timeout_ms", cfg->signer.timeout_ms); + cJSON_AddItemToObject(user_settings, "signer", signer); + } + char* settings_json = cJSON_PrintUnformatted(user_settings); cJSON_Delete(user_settings); if (!settings_json) return -1; @@ -1909,7 +1938,10 @@ static int write_sudoers_file(const char* service_user) { } static int install_system_service_with_dedicated_user(const didactyl_config_t* cfg, const char* agent_name) { - if (!cfg || cfg->keys.nsec[0] == '\0') return -1; + /* local mode requires an nsec to embed in ExecStart; remote signer modes + do not hold the nsec and instead pass --signer flags. */ + if (!cfg) return -1; + if (strcmp(cfg->signer.mode, "local") == 0 && cfg->keys.nsec[0] == '\0') return -1; if (geteuid() != 0) { fprintf(stderr, " Requesting sudo authentication for system install...\n"); @@ -2007,6 +2039,33 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c snprintf(env_line, sizeof(env_line), "Environment=SSL_CERT_FILE=%s\\n", ca_bundle); } + /* Build the key/signer argument tail for ExecStart. + * - local mode: embeds the nsec directly (legacy behavior). + * - nsigner_unix mode: passes --signer/--signer-socket/--signer-role/--signer-timeout + * so the service process reconstructs the remote signer without holding the nsec. + * - nsigner_tcp mode: passes --signer-tcp host:port plus role/timeout. + * The agent pubkey, admin, LLM and relays are recovered from Nostr at boot. */ + char key_args[768] = {0}; + if (strcmp(cfg->signer.mode, "nsigner_unix") == 0) { + snprintf(key_args, sizeof(key_args), + "--signer nsigner_unix --signer-role %s --signer-timeout %d%s%s", + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000, + cfg->signer.socket_name[0] ? " --signer-socket " : "", + cfg->signer.socket_name[0] ? cfg->signer.socket_name : ""); + } else if (strcmp(cfg->signer.mode, "nsigner_tcp") == 0) { + char host_port[OW_MAX_SIGNER_HOST_LEN + 16] = {0}; + snprintf(host_port, sizeof(host_port), "%s:%d", cfg->signer.tcp_host, cfg->signer.tcp_port); + snprintf(key_args, sizeof(key_args), + "--signer-tcp %s --signer-role %s --signer-timeout %d", + host_port, + cfg->signer.role[0] ? cfg->signer.role : "main", + cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000); + } else { + /* local mode (default): embed the nsec directly in ExecStart. */ + snprintf(key_args, sizeof(key_args), "--nsec %s", cfg->keys.nsec); + } + char service_content[8192] = {0}; int svc_len = snprintf(service_content, sizeof(service_content), @@ -2021,7 +2080,7 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c "User=%s\n" "Group=%s\n" "WorkingDirectory=%s\n" - "ExecStart=%s --nsec %s --debug 3\n" + "ExecStart=%s --debug 3 %s\n" "%s" "SyslogIdentifier=%s\n" "NoNewPrivileges=false\n" @@ -2040,7 +2099,7 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c service_user, home, bin_path, - cfg->keys.nsec, + key_args, env_line, service_user, home); @@ -2076,7 +2135,11 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c fprintf(stderr, " User: %s\n", service_user); fprintf(stderr, " Home: %s\n", home); fprintf(stderr, " Binary: %s\n", bin_path); - fprintf(stderr, " Nsec: embedded directly in ExecStart\n"); + if (strcmp(cfg->signer.mode, "local") == 0) { + fprintf(stderr, " Nsec: embedded directly in ExecStart\n"); + } else { + fprintf(stderr, " Signer: %s (nsec held by n_signer, not the agent)\n", cfg->signer.mode); + } fprintf(stderr, " Sudoers: /etc/sudoers.d/%s\n", service_user); fprintf(stderr, " Unit: %s\n", service_path); fprintf(stderr, "\n Useful commands:\n"); @@ -2118,10 +2181,11 @@ static int new_agent_identity_step(didactyl_config_t* cfg) { render_wizard_page_header("Step 2 of 7", "New Agent Setup -- Identity"); print_option('g', "enerate a new Nostr keypair"); print_option('p', "rovide an existing nsec"); + print_option('s', "ign with a running n_signer"); print_option('b', "ack"); print_option('q', "uit"); - wizard_option_t opts[] = {{'g', ""}, {'p', ""}, {'b', ""}, {'q', ""}}; - char c = read_menu_choice(opts, 4); + wizard_option_t opts[] = {{'g', ""}, {'p', ""}, {'s', ""}, {'b', ""}, {'q', ""}}; + char c = read_menu_choice(opts, 5); if (c == 'q') return -1; if (c == 'b') return 1; @@ -2171,6 +2235,38 @@ static int new_agent_identity_step(didactyl_config_t* cfg) { } } + if (c == 's') { + snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", "nsigner_unix"); + if (read_line_prompt(" Socket name (empty for auto-discovery): ", cfg->signer.socket_name, sizeof(cfg->signer.socket_name)) != 0) return -1; + if (read_line_prompt(" Role [main]: ", cfg->signer.role, sizeof(cfg->signer.role)) != 0) return -1; + if (cfg->signer.role[0] == '\0') snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", "main"); + char timeout_buf[32] = {0}; + if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1; + cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000; + if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000; + + fprintf(stderr, " Checking connectivity to n_signer...\n"); +#if defined(NOSTR_ENABLE_NSIGNER_CLIENT) + nostr_signer_t* signer = nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL, + cfg->signer.role, + cfg->signer.timeout_ms); + if (!signer) { + fprintf(stderr, "%sFailed to initialize n_signer client.%s\n", ANSI_RED, ANSI_RESET); + continue; + } + if (nostr_signer_get_public_key(signer, cfg->keys.public_key_hex) != 0) { + fprintf(stderr, "%sFailed to retrieve public key from n_signer.%s\n", ANSI_RED, ANSI_RESET); + nostr_signer_free(signer); + continue; + } + nostr_signer_free(signer); + fprintf(stderr, "%sConnectivity check: OK. Pubkey: %s%s\n", ANSI_YELLOW, cfg->keys.public_key_hex, ANSI_RESET); +#else + fprintf(stderr, "%sThis build was compiled without n_signer client support (NOSTR_ENABLE_NSIGNER_CLIENT). Connectivity check skipped; the agent will fail at startup in this mode.%s\n", + ANSI_RED, ANSI_RESET); +#endif + } + return 0; } } diff --git a/src/signer_health.c b/src/signer_health.c new file mode 100644 index 0000000..c675a6e --- /dev/null +++ b/src/signer_health.c @@ -0,0 +1,132 @@ +#define _POSIX_C_SOURCE 200809L + +#include "signer_health.h" + +#include +#include +#include + +#include "../nostr_core_lib/nostr_core/nostr_common.h" + +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static signer_health_state_t g_state = SIGNER_HEALTH_UNKNOWN; +static char g_mode[32] = {0}; +static char g_last_reason[SIGNER_HEALTH_REASON_LEN] = {0}; +static time_t g_last_error_ts = 0; +static time_t g_last_ok_ts = 0; + +void signer_health_set_mode(const char* mode) { + pthread_mutex_lock(&g_lock); + if (mode && mode[0]) { + snprintf(g_mode, sizeof(g_mode), "%s", mode); + } else { + g_mode[0] = '\0'; + } + pthread_mutex_unlock(&g_lock); +} + +void signer_health_reset(void) { + pthread_mutex_lock(&g_lock); + g_state = SIGNER_HEALTH_UNKNOWN; + g_mode[0] = '\0'; + g_last_reason[0] = '\0'; + g_last_error_ts = 0; + g_last_ok_ts = 0; + pthread_mutex_unlock(&g_lock); +} + +/* Classify a nostr error code into a short category label used both for + * the health reason string and for log clarity. The categories map to + * the failure modes called out in Phase 4 item 18: + * transport/IO - network or socket failure talking to n_signer + * auth/policy - n_signer rejected the request (auth envelope, policy) + * crypto - the signer performed work but the crypto op failed + * (e.g. decrypt of malformed ciphertext). This is NOT + * necessarily a signer outage, but we still record it + * so the operator can see recent signer activity. + * unknown - anything else. + * + * Note: nostr_core_lib does not currently expose dedicated nsigner + * transport/auth error codes; remote signer failures surface as + * NOSTR_ERROR_IO_FAILED / NOSTR_ERROR_NETWORK_FAILED (transport) or + * NOSTR_ERROR_CRYPTO_FAILED (the nsigner client maps server-side + * rejections to crypto-failed when it cannot be more specific). We + * treat IO/network as the strong "signer unreachable" signal. */ +static const char* classify_rc(int rc) { + if (rc == NOSTR_ERROR_IO_FAILED || rc == NOSTR_ERROR_NETWORK_FAILED) { + return "transport/IO"; + } + if (rc == NOSTR_ERROR_CRYPTO_FAILED) { + return "crypto/auth"; + } + if (rc == NOSTR_ERROR_MEMORY_FAILED) { + return "resource"; + } + if (rc == NOSTR_ERROR_INVALID_INPUT) { + return "invalid-input"; + } + return "unknown"; +} + +void signer_health_record_ok(void) { + int transitioned_from_unhealthy = 0; + pthread_mutex_lock(&g_lock); + time_t now = time(NULL); + if (g_state == SIGNER_HEALTH_UNHEALTHY) { + transitioned_from_unhealthy = 1; + } + g_state = SIGNER_HEALTH_HEALTHY; + g_last_ok_ts = now; + /* Keep last_reason/last_error_ts so the operator can see the most + * recent prior failure even after recovery. */ + pthread_mutex_unlock(&g_lock); + + if (transitioned_from_unhealthy) { + fprintf(stderr, + "[didactyl] signer health transition: unhealthy -> healthy (mode=%s)\n", + g_mode[0] ? g_mode : "local"); + } +} + +void signer_health_record_failure(int nostr_rc, const char* detail) { + int transitioned_from_healthy = 0; + char reason[SIGNER_HEALTH_REASON_LEN]; + const char* cls = classify_rc(nostr_rc); + if (detail && detail[0]) { + snprintf(reason, sizeof(reason), "%s: %s (rc=%d)", cls, detail, nostr_rc); + } else { + snprintf(reason, sizeof(reason), "%s (rc=%d)", cls, nostr_rc); + } + + pthread_mutex_lock(&g_lock); + time_t now = time(NULL); + if (g_state == SIGNER_HEALTH_HEALTHY) { + transitioned_from_healthy = 1; + } + g_state = SIGNER_HEALTH_UNHEALTHY; + snprintf(g_last_reason, sizeof(g_last_reason), "%s", reason); + g_last_error_ts = now; + pthread_mutex_unlock(&g_lock); + + if (transitioned_from_healthy) { + fprintf(stderr, + "[didactyl] signer health transition: healthy -> unhealthy " + "(mode=%s, reason=%s)\n", + g_mode[0] ? g_mode : "local", + reason); + } +} + +int signer_health_snapshot(signer_health_snapshot_t* out) { + if (!out) { + return -1; + } + pthread_mutex_lock(&g_lock); + out->state = g_state; + snprintf(out->mode, sizeof(out->mode), "%s", g_mode); + snprintf(out->last_reason, sizeof(out->last_reason), "%s", g_last_reason); + out->last_error_ts = g_last_error_ts; + out->last_ok_ts = g_last_ok_ts; + pthread_mutex_unlock(&g_lock); + return 0; +} diff --git a/src/signer_health.h b/src/signer_health.h new file mode 100644 index 0000000..47709e7 --- /dev/null +++ b/src/signer_health.h @@ -0,0 +1,59 @@ +#ifndef DIDACTYL_SIGNER_HEALTH_H +#define DIDACTYL_SIGNER_HEALTH_H + +#include + +/* Signer runtime health tracking (Phase 4 item 18/19). + * + * Lightweight process-global state recording whether the active signer + * provider (local or remote n_signer) is currently considered healthy, + * along with the last error reason and timestamp. This is surfaced via + * the HTTP /api/status endpoint so an operator can detect a signing + * outage out-of-band (without requiring a signed admin DM to come back). + * + * The state is intentionally minimal and lock-protected; it is updated + * from central signer call paths (nostr_handler self-encrypt/decrypt, + * DM encrypt/decrypt, main.c startup connectivity check). It is NOT a + * full health-check daemon: it reflects the outcome of the last signer + * operation that was attempted. + */ + +typedef enum { + SIGNER_HEALTH_UNKNOWN = 0, /* no signer operation attempted yet */ + SIGNER_HEALTH_HEALTHY = 1, + SIGNER_HEALTH_UNHEALTHY = 2 +} signer_health_state_t; + +#define SIGNER_HEALTH_REASON_LEN 256 + +typedef struct { + signer_health_state_t state; + char mode[32]; /* snapshot of cfg.signer.mode */ + char last_reason[SIGNER_HEALTH_REASON_LEN]; /* human-readable last error */ + time_t last_error_ts; /* unix ts of last unhealthy transition */ + time_t last_ok_ts; /* unix ts of last healthy observation */ +} signer_health_snapshot_t; + +/* Record the configured signer mode at startup. Call once after the + * signer is constructed. Safe to call multiple times. */ +void signer_health_set_mode(const char* mode); + +/* Mark the signer healthy and record a successful operation timestamp. + * Emits a one-line warning if this is a transition from unhealthy -> healthy. */ +void signer_health_record_ok(void); + +/* Mark the signer unhealthy with a reason string and the nostr error code + * (from nostr_common.h). The reason is formatted as ": ". + * Emits a one-line warning if this is a transition from healthy -> unhealthy. + * Pass rc=0 only if you do not have a nostr error code (the detail string + * should then fully describe the failure). */ +void signer_health_record_failure(int nostr_rc, const char* detail); + +/* Thread-safe snapshot copy for the HTTP status endpoint. Returns 0 on + * success, -1 if out is NULL. */ +int signer_health_snapshot(signer_health_snapshot_t* out); + +/* Reset state (used by tests / reinit). */ +void signer_health_reset(void); + +#endif /* DIDACTYL_SIGNER_HEALTH_H */ diff --git a/src/tools/tool_blossom.c b/src/tools/tool_blossom.c index 8c1a6a3..7a551ce 100644 --- a/src/tools/tool_blossom.c +++ b/src/tools/tool_blossom.c @@ -117,12 +117,26 @@ char* execute_blossom_upload(tools_context_t* ctx, const char* args_json) { } blossom_blob_descriptor_t d; - int rc = blossom_upload_file(server->valuestring, + /* Phase 3 item 16: route the Blossom upload auth/signature through the + * process signer when wired into the tools context so remote signer modes + * keep the nsec out of the agent. Falls back to the legacy raw-key path + * when the signer is NULL (local mode / pre-Phase-3). */ + int rc; + if (ctx->signer) { + rc = blossom_upload_file_with_signer(server->valuestring, + full_path, + (content_type && cJSON_IsString(content_type) && content_type->valuestring) ? content_type->valuestring : NULL, + ctx->signer, + 30, + &d); + } else { + 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"); @@ -235,7 +249,16 @@ char* execute_blossom_delete(tools_context_t* ctx, const char* args_json) { 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); + /* Phase 3 item 16: route the Blossom delete auth/signature through the + * process signer when wired into the tools context so remote signer modes + * keep the nsec out of the agent. Falls back to the legacy raw-key path + * when the signer is NULL (local mode / pre-Phase-3). */ + int rc; + if (ctx->signer) { + rc = blossom_delete_with_signer(server->valuestring, sha->valuestring, ctx->signer, 15); + } else { + 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"); diff --git a/src/tools/tool_config.c b/src/tools/tool_config.c index 9ab25a2..6dd64b3 100644 --- a/src/tools/tool_config.c +++ b/src/tools/tool_config.c @@ -37,6 +37,20 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, if (!ctx || !ctx->cfg || !out_ciphertext) return -1; const char* plain = plaintext ? plaintext : ""; + + /* Phase 3 item 13: route self-encrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_encrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + plain, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_ciphertext = signer_out; + return 0; + } + size_t out_cap = (strlen(plain) * 4U) + 1024U; char* ciphertext = (char*)malloc(out_cap); if (!ciphertext) return -1; @@ -58,6 +72,19 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) { if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1; + /* Phase 3 item 13: route self-decrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_decrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + ciphertext, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_plaintext = signer_out; + return 0; + } + size_t out_cap = strlen(ciphertext) + 1024U; char* plaintext = (char*)malloc(out_cap); if (!plaintext) return -1; diff --git a/src/tools/tool_memory.c b/src/tools/tool_memory.c index 56c996b..11e2949 100644 --- a/src/tools/tool_memory.c +++ b/src/tools/tool_memory.c @@ -58,6 +58,21 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, if (!ctx || !ctx->cfg || !out_ciphertext) return -1; const char* plain = plaintext ? plaintext : ""; + + /* Phase 3 item 13: route self-encrypt through the process signer when + * wired into the tools context so remote signer modes keep the nsec out + * of the agent. Falls back to the legacy raw-key path otherwise. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_encrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + plain, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_ciphertext = signer_out; + return 0; + } + size_t out_cap = (strlen(plain) * 4U) + 1024U; char* ciphertext = (char*)malloc(out_cap); if (!ciphertext) return -1; @@ -79,6 +94,19 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) { if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1; + /* Phase 3 item 13: route self-decrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_decrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + ciphertext, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_plaintext = signer_out; + return 0; + } + size_t out_cap = strlen(ciphertext) + 1024U; char* plaintext = (char*)malloc(out_cap); if (!plaintext) return -1; diff --git a/src/tools/tool_nostr_dm.c b/src/tools/tool_nostr_dm.c index f5e2ed9..532e86d 100644 --- a/src/tools/tool_nostr_dm.c +++ b/src/tools/tool_nostr_dm.c @@ -83,28 +83,43 @@ char* execute_nostr_encrypt(tools_context_t* ctx, const char* args_json) { return json_error_local("nostr_encrypt requires recipient_pubkey hex and plaintext"); } - unsigned char recipient_pubkey[32]; - if (nostr_hex_to_bytes(recipient->valuestring, recipient_pubkey, sizeof(recipient_pubkey)) != 0) { - cJSON_Delete(args); - return json_error_local("nostr_encrypt invalid recipient_pubkey"); - } + /* Phase 3 item 13: route NIP-44 encrypt through the process signer when + * wired into the tools context. The signer verb takes a hex peer pubkey + * and returns a heap string. Falls back to the legacy raw-key path. */ + char* ciphertext = NULL; + if (ctx->signer) { + int rc = nostr_signer_nip44_encrypt(ctx->signer, + recipient->valuestring, + plaintext->valuestring, + &ciphertext); + if (rc != NOSTR_SUCCESS || !ciphertext) { + cJSON_Delete(args); + return json_error_local("nostr_encrypt failed"); + } + } else { + unsigned char recipient_pubkey[32]; + if (nostr_hex_to_bytes(recipient->valuestring, recipient_pubkey, sizeof(recipient_pubkey)) != 0) { + cJSON_Delete(args); + return json_error_local("nostr_encrypt invalid recipient_pubkey"); + } - size_t out_cap = (strlen(plaintext->valuestring) * 4U) + 1024U; - char* ciphertext = (char*)malloc(out_cap); - if (!ciphertext) { - cJSON_Delete(args); - return json_error_local("allocation failure"); - } + size_t out_cap = (strlen(plaintext->valuestring) * 4U) + 1024U; + ciphertext = (char*)malloc(out_cap); + if (!ciphertext) { + cJSON_Delete(args); + return json_error_local("allocation failure"); + } - int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key, - recipient_pubkey, - plaintext->valuestring, - ciphertext, - out_cap); - if (rc != NOSTR_SUCCESS) { - free(ciphertext); - cJSON_Delete(args); - return json_error_local("nostr_encrypt failed"); + int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key, + recipient_pubkey, + plaintext->valuestring, + ciphertext, + out_cap); + if (rc != NOSTR_SUCCESS) { + free(ciphertext); + cJSON_Delete(args); + return json_error_local("nostr_encrypt failed"); + } } cJSON* out = cJSON_CreateObject(); @@ -138,28 +153,42 @@ char* execute_nostr_decrypt(tools_context_t* ctx, const char* args_json) { return json_error_local("nostr_decrypt requires sender_pubkey hex and ciphertext"); } - unsigned char sender_pubkey[32]; - if (nostr_hex_to_bytes(sender->valuestring, sender_pubkey, sizeof(sender_pubkey)) != 0) { - cJSON_Delete(args); - return json_error_local("nostr_decrypt invalid sender_pubkey"); - } + /* Phase 3 item 13: route NIP-44 decrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + char* plaintext = NULL; + if (ctx->signer) { + int rc = nostr_signer_nip44_decrypt(ctx->signer, + sender->valuestring, + ciphertext->valuestring, + &plaintext); + if (rc != NOSTR_SUCCESS || !plaintext) { + cJSON_Delete(args); + return json_error_local("nostr_decrypt failed"); + } + } else { + unsigned char sender_pubkey[32]; + if (nostr_hex_to_bytes(sender->valuestring, sender_pubkey, sizeof(sender_pubkey)) != 0) { + cJSON_Delete(args); + return json_error_local("nostr_decrypt invalid sender_pubkey"); + } - size_t out_cap = strlen(ciphertext->valuestring) + 1024U; - char* plaintext = (char*)malloc(out_cap); - if (!plaintext) { - cJSON_Delete(args); - return json_error_local("allocation failure"); - } + size_t out_cap = strlen(ciphertext->valuestring) + 1024U; + plaintext = (char*)malloc(out_cap); + if (!plaintext) { + cJSON_Delete(args); + return json_error_local("allocation failure"); + } - int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key, - sender_pubkey, - ciphertext->valuestring, - plaintext, - out_cap); - if (rc != NOSTR_SUCCESS) { - free(plaintext); - cJSON_Delete(args); - return json_error_local("nostr_decrypt failed"); + int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key, + sender_pubkey, + ciphertext->valuestring, + plaintext, + out_cap); + if (rc != NOSTR_SUCCESS) { + free(plaintext); + cJSON_Delete(args); + return json_error_local("nostr_decrypt failed"); + } } cJSON* out = cJSON_CreateObject(); diff --git a/src/tools/tool_task.c b/src/tools/tool_task.c index 5d78a88..c6983a2 100644 --- a/src/tools/tool_task.c +++ b/src/tools/tool_task.c @@ -60,6 +60,20 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, if (!ctx || !ctx->cfg || !out_ciphertext) return -1; const char* plain = plaintext ? plaintext : ""; + + /* Phase 3 item 13: route self-encrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_encrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + plain, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_ciphertext = signer_out; + return 0; + } + size_t out_cap = (strlen(plain) * 4U) + 1024U; char* ciphertext = (char*)malloc(out_cap); if (!ciphertext) return -1; @@ -81,6 +95,19 @@ static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) { if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1; + /* Phase 3 item 13: route self-decrypt through the process signer when + * wired into the tools context. Falls back to the legacy raw-key path. */ + if (ctx->signer) { + char* signer_out = NULL; + int rc = nostr_signer_nip44_decrypt(ctx->signer, + ctx->cfg->keys.public_key_hex, + ciphertext, + &signer_out); + if (rc != NOSTR_SUCCESS || !signer_out) return -1; + *out_plaintext = signer_out; + return 0; + } + size_t out_cap = strlen(ciphertext) + 1024U; char* plaintext = (char*)malloc(out_cap); if (!plaintext) return -1; diff --git a/src/tools/tools.h b/src/tools/tools.h index c4be8ca..e543dbe 100644 --- a/src/tools/tools.h +++ b/src/tools/tools.h @@ -2,11 +2,17 @@ #define DIDACTYL_TOOLS_H #include "../config.h" +#include "../nostr_core_lib/nostr_core/nostr_signer.h" struct trigger_manager; typedef struct { didactyl_config_t* cfg; + /* Optional process-lifetime signer handle (non-owning). When set, + * NIP-04/44 encrypt/decrypt tool call sites route through the signer + * verbs so remote signer modes keep the nsec out of the agent. Falls + * back to the legacy raw-key path when NULL (local mode / pre-Phase-3). */ + nostr_signer_t* signer; struct trigger_manager* trigger_manager; int template_sender_tier; const char* template_current_user_message; @@ -17,6 +23,10 @@ typedef struct { int tools_init(tools_context_t* ctx, didactyl_config_t* cfg); void tools_cleanup(tools_context_t* ctx); +/* Wire a process-lifetime signer handle into the tools context. Non-owning: + * caller must keep the signer alive for the context's lifetime and free it + * after tools_cleanup. Passing NULL restores the legacy raw-key fallback. */ +void tools_set_signer(tools_context_t* ctx, nostr_signer_t* signer); char* tools_build_openai_schema_json(const tools_context_t* ctx); char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json); diff --git a/src/tools/tools_dispatch.c b/src/tools/tools_dispatch.c index 3777741..7c7d2df 100644 --- a/src/tools/tools_dispatch.c +++ b/src/tools/tools_dispatch.c @@ -21,6 +21,7 @@ int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) { if (!ctx || !cfg) return -1; memset(ctx, 0, sizeof(*ctx)); ctx->cfg = cfg; + ctx->signer = NULL; ctx->trigger_manager = NULL; ctx->template_sender_tier = 0; ctx->template_current_user_message = NULL; @@ -30,6 +31,11 @@ int tools_init_legacy(tools_context_t* ctx, didactyl_config_t* cfg) { return 0; } +void tools_set_signer(tools_context_t* ctx, nostr_signer_t* signer) { + if (!ctx) return; + ctx->signer = signer; +} + void tools_cleanup_legacy(tools_context_t* ctx) { if (!ctx) return; memset(ctx, 0, sizeof(*ctx)); diff --git a/test_output.log b/test_output.log new file mode 100644 index 0000000..8f04bd0 --- /dev/null +++ b/test_output.log @@ -0,0 +1,70 @@ +Didactyl - sovereign Nostr agent + +Usage: + ./didactyl [options] + +Options: + -h, --help + Show this help text and exit. + -v, --version + Show version and exit. + --config + Path to config/genesis JSONC file (default: ./genesis.jsonc). + If file is missing, startup falls back to minimal nsec-only mode. + --nsec + Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC. + --admin + Administrator pubkey override; accepts npub1... or 64-char hex. + --api-port + Enable HTTP API and bind to this port (1-65535). + --api-bind + Enable HTTP API and bind address (example: 127.0.0.1). + --debug <0-5> + Log level (0=fatal, 1=error, 2=warn, 3=info, 4=debug, 5=trace). + --signer + Signer provider: local | nsigner_unix | nsigner_tcp. + Overrides signer.mode in config and DIDACTYL_SIGNER. + In nsigner_* modes --nsec/DIDACTYL_NSEC are not required. + --signer-socket + Abstract socket name for nsigner_unix (without leading @). + --signer-role + n_signer role selector (default: main). + --signer-timeout + Per-call signer timeout in milliseconds (default: 15000). + --signer-tcp + Shorthand for --signer nsigner_tcp with host/port parsed. + --dump-schemas + Print tool schemas JSON and exit. + --test-tool + Execute one tool call locally and print JSON result, then exit. + +Environment: + DIDACTYL_NSEC + Fallback private key when --nsec is not provided (local mode only). + DIDACTYL_SIGNER + Fallback signer mode when --signer is not provided. + +Examples: + 1) Genesis-based startup + ./didactyl --config genesis.jsonc + + 2) nsec-only startup (no config file) + ./didactyl --nsec nsec1... + + 3) nsec via environment variable + DIDACTYL_NSEC=nsec1... ./didactyl + + 4) Override API bind/port + ./didactyl --config genesis.jsonc --api-bind 127.0.0.1 --api-port 8484 + + 5) Sign with a running n_signer (separate the nsec from the agent) + ./didactyl --signer nsigner_unix --signer-socket nsigner_hairy_dog --admin npub1... + + 6) n_signer via TCP + ./didactyl --signer-tcp 127.0.0.1:7777 --admin npub1... + + 7) Dump tool schemas + ./didactyl --dump-schemas + + 8) Test a tool invocation + ./didactyl --test-tool config_recall '{"config_name":"llm_config"}' diff --git a/tests/results/20260708T142949Z/agent_debug.log b/tests/results/20260708T142949Z/agent_debug.log new file mode 100644 index 0000000..e6a5bc4 --- /dev/null +++ b/tests/results/20260708T142949Z/agent_debug.log @@ -0,0 +1 @@ +[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting diff --git a/tests/results/20260708T142949Z/runtime_test_genesis.jsonc b/tests/results/20260708T142949Z/runtime_test_genesis.jsonc new file mode 100644 index 0000000..db71aa4 --- /dev/null +++ b/tests/results/20260708T142949Z/runtime_test_genesis.jsonc @@ -0,0 +1,59 @@ +{ + // TEST CONFIGURATION + // Use disposable keys/accounts only. + "key": { + "nsec": "d3011e59954403fb84729ab0e9fb239ba0f0238b3f353dfb5c08a32f4b3d21a1" + }, + "admin": { + "pubkey": "254d7a7f68b4443f5430564eb3a726f09aea57d27e75ab2b8ef57176f9ca3dac" + }, + "llm": { + "provider": "https://api.ppq.ai", + "api_key": "sk-SBI2Pga0J6n8jpPorHbRHC", + "model": "claude-haiku-4.5", + "base_url": "https://api.ppq.ai", + "max_tokens": 10000, + "temperature": 0.7 + }, + "api": { + "enabled": true, + "port": 8485, + "bind_address": "127.0.0.1" + }, + "encrypted_events": [ + { + "kind": 30078, + "d_tag": "user-settings", + "content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"https://api.ppq.ai\",\"api_key\":\"sk-SBI2Pga0J6n8jpPorHbRHC\",\"model\":\"claude-haiku-4.5\",\"base_url\":\"https://api.ppq.ai\",\"max_tokens\":10000,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1REPLACE_WITH_TEST_ADMIN_PUBKEY\",\"dm_protocol\":\"both\",\"max_turns\":40}}" + } + ], + "startup_events": [ + { + "kind": 0, + "content_fields": { + "name": "Didactyl Test Agent", + "about": "Automated test instance" + }, + "tags": [] + }, + { + "kind": 10002, + "content": "", + "tags": [ + ["r", "wss://relay.damus.io"], + ["r", "wss://relay.primal.net"] + ] + }, + { + "kind": 31124, + "content": "# Test Agent\n\nYou are a test agent. Respond to requests and use tools when needed.", + "tags": [ + ["d", "identity_and_rules"], + ["app", "didactyl"], + ["scope", "private"], + ["trigger", "dm"], + ["filter", "{\"from\":\"admin\"}"] + ] + } + ] +} diff --git a/tests/results/20260708T143054Z/agent_debug.log b/tests/results/20260708T143054Z/agent_debug.log new file mode 100644 index 0000000..e6a5bc4 --- /dev/null +++ b/tests/results/20260708T143054Z/agent_debug.log @@ -0,0 +1 @@ +[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting diff --git a/tests/results/20260708T143054Z/runtime_test_genesis.jsonc b/tests/results/20260708T143054Z/runtime_test_genesis.jsonc new file mode 100644 index 0000000..db71aa4 --- /dev/null +++ b/tests/results/20260708T143054Z/runtime_test_genesis.jsonc @@ -0,0 +1,59 @@ +{ + // TEST CONFIGURATION + // Use disposable keys/accounts only. + "key": { + "nsec": "d3011e59954403fb84729ab0e9fb239ba0f0238b3f353dfb5c08a32f4b3d21a1" + }, + "admin": { + "pubkey": "254d7a7f68b4443f5430564eb3a726f09aea57d27e75ab2b8ef57176f9ca3dac" + }, + "llm": { + "provider": "https://api.ppq.ai", + "api_key": "sk-SBI2Pga0J6n8jpPorHbRHC", + "model": "claude-haiku-4.5", + "base_url": "https://api.ppq.ai", + "max_tokens": 10000, + "temperature": 0.7 + }, + "api": { + "enabled": true, + "port": 8485, + "bind_address": "127.0.0.1" + }, + "encrypted_events": [ + { + "kind": 30078, + "d_tag": "user-settings", + "content": "{\"v\":2,\"updatedAt\":0,\"global_llm\":{\"provider\":\"https://api.ppq.ai\",\"api_key\":\"sk-SBI2Pga0J6n8jpPorHbRHC\",\"model\":\"claude-haiku-4.5\",\"base_url\":\"https://api.ppq.ai\",\"max_tokens\":10000,\"temperature\":0.7},\"didactyl\":{\"admin_pubkey\":\"npub1REPLACE_WITH_TEST_ADMIN_PUBKEY\",\"dm_protocol\":\"both\",\"max_turns\":40}}" + } + ], + "startup_events": [ + { + "kind": 0, + "content_fields": { + "name": "Didactyl Test Agent", + "about": "Automated test instance" + }, + "tags": [] + }, + { + "kind": 10002, + "content": "", + "tags": [ + ["r", "wss://relay.damus.io"], + ["r", "wss://relay.primal.net"] + ] + }, + { + "kind": 31124, + "content": "# Test Agent\n\nYou are a test agent. Respond to requests and use tools when needed.", + "tags": [ + ["d", "identity_and_rules"], + ["app", "didactyl"], + ["scope", "private"], + ["trigger", "dm"], + ["filter", "{\"from\":\"admin\"}"] + ] + } + ] +}