Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3534e6eee | ||
|
|
3ed3733b76 | ||
|
|
4430c36bcb |
@@ -123,7 +123,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
|
||||
src/tools/tool_task.c src/tools/tool_nostr_list.c src/tools/tool_nostr_block.c src/tools/tool_local.c \
|
||||
src/tools/tool_skill.c src/tools/tool_nostr_post.c src/tools/tool_memory.c src/tools/tool_config.c src/tools/tool_cashu_wallet.c src/tools/tool_blossom.c src/trigger_manager.c \
|
||||
src/cashu_wallet.c src/nostr_block_list.c src/prompt_template.c src/http_api.c src/setup_wizard.c src/mongoose.c src/debug.c \
|
||||
src/json_to_markdown.c src/context_roles.c src/context_format.c \
|
||||
src/json_to_markdown.c src/context_roles.c src/context_format.c src/signer_health.c \
|
||||
-o /build/didactyl_static \
|
||||
$NOSTR_LIB \
|
||||
-lsecp256k1 \
|
||||
|
||||
5
Makefile
5
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) \
|
||||
|
||||
@@ -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.53
|
||||
|
||||
**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.53 — Add signer_health.c to Dockerfile.alpine-musl source list (was missing, causing static build failure)
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
|
||||
1
didactyl.pid
Normal file
1
didactyl.pid
Normal file
@@ -0,0 +1 @@
|
||||
131350
|
||||
70
didactyl_nsigner.log
Normal file
70
didactyl_nsigner.log
Normal file
@@ -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>
|
||||
Path to config/genesis JSONC file (default: ./genesis.jsonc).
|
||||
If file is missing, startup falls back to minimal nsec-only mode.
|
||||
--nsec <nsec_or_hex>
|
||||
Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.
|
||||
--admin <npub_or_hex>
|
||||
Administrator pubkey override; accepts npub1... or 64-char hex.
|
||||
--api-port <port>
|
||||
Enable HTTP API and bind to this port (1-65535).
|
||||
--api-bind <addr>
|
||||
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 <mode>
|
||||
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 <name>
|
||||
Abstract socket name for nsigner_unix (without leading @).
|
||||
--signer-role <role>
|
||||
n_signer role selector (default: main).
|
||||
--signer-timeout <ms>
|
||||
Per-call signer timeout in milliseconds (default: 15000).
|
||||
--signer-tcp <host:port>
|
||||
Shorthand for --signer nsigner_tcp with host/port parsed.
|
||||
--dump-schemas
|
||||
Print tool schemas JSON and exit.
|
||||
--test-tool <name> <args_json>
|
||||
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"}'
|
||||
1
exit_code.txt
Normal file
1
exit_code.txt
Normal file
@@ -0,0 +1 @@
|
||||
1
|
||||
70
help_output.txt
Normal file
70
help_output.txt
Normal file
@@ -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>
|
||||
Path to config/genesis JSONC file (default: ./genesis.jsonc).
|
||||
If file is missing, startup falls back to minimal nsec-only mode.
|
||||
--nsec <nsec_or_hex>
|
||||
Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.
|
||||
--admin <npub_or_hex>
|
||||
Administrator pubkey override; accepts npub1... or 64-char hex.
|
||||
--api-port <port>
|
||||
Enable HTTP API and bind to this port (1-65535).
|
||||
--api-bind <addr>
|
||||
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 <mode>
|
||||
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 <name>
|
||||
Abstract socket name for nsigner_unix (without leading @).
|
||||
--signer-role <role>
|
||||
n_signer role selector (default: main).
|
||||
--signer-timeout <ms>
|
||||
Per-call signer timeout in milliseconds (default: 15000).
|
||||
--signer-tcp <host:port>
|
||||
Shorthand for --signer nsigner_tcp with host/port parsed.
|
||||
--dump-schemas
|
||||
Print tool schemas JSON and exit.
|
||||
--test-tool <name> <args_json>
|
||||
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"}'
|
||||
1
nostr_core_lib.old
Submodule
1
nostr_core_lib.old
Submodule
Submodule nostr_core_lib.old added at af06f23939
668
nsigner.log
Normal file
668
nsigner.log
Normal file
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
(none)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
(none)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
[1mRoles[0m
|
||||
Role Purpose Curve Selector
|
||||
-------------------- ------------ ------------ ------------
|
||||
main nostr secp256k1 nostr_index
|
||||
|
||||
[1mActivity (latest first)[0m
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[24A
|
||||
================================================================================
|
||||
n_signer v0.0.33
|
||||
================================================================================
|
||||
> Main Menu
|
||||
|
||||
|
||||
[1mConnections[0m
|
||||
listen: unix @didactyl_test_signer
|
||||
client: nsigner --socket-name didactyl_test_signer client '<json>'
|
||||
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
|
||||
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
|
||||
|
||||
[4mq[0m/x quit
|
||||
[4ml[0m lock/reunlock
|
||||
[4mr[0m refresh
|
||||
[4mA[0m toggle auto-approve
|
||||
|
||||
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
|
||||
1
nsigner.pid
Normal file
1
nsigner.pid
Normal file
@@ -0,0 +1 @@
|
||||
131270
|
||||
77
plans/fix_trigger_adoption_gate.md
Normal file
77
plans/fix_trigger_adoption_gate.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Fix: Trigger Manager Bypasses Skill Adoption List
|
||||
|
||||
## Problem
|
||||
|
||||
`trigger_manager_load_from_skills()` and `trigger_manager_reconcile_from_relays()`
|
||||
activate cron/webhook/subscription triggers for **every** self-authored skill event
|
||||
(kind 31123/31124) that has trigger metadata, without checking whether the skill is
|
||||
on the kind 10123 adoption list.
|
||||
|
||||
This means a skill that was published but never adopted (or was un-adopted) will
|
||||
still have its cron trigger firing.
|
||||
|
||||
Contrast: `agent.c:refresh_adopted_skills_cache_if_needed()` properly fetches the
|
||||
kind 10123 list, parses `"a"` tags, and only loads adopted skills — and also checks
|
||||
the `enabled` tag. The trigger manager was written as a parallel system that skips
|
||||
both gates.
|
||||
|
||||
## Root Cause
|
||||
|
||||
- `trigger_manager.c:939` — `trigger_manager_load_from_skills()`: no reference to
|
||||
kind 10123 or adoption anywhere in the function. Calls `trigger_manager_add()`
|
||||
unconditionally.
|
||||
- `trigger_manager.c:592` — `trigger_manager_reconcile_from_relays()`: same bug.
|
||||
Currently disabled (`enable_periodic_reconcile = 0` at line 1575) but should be
|
||||
fixed before re-enabling.
|
||||
- Neither function checks the `enabled` tag either, unlike
|
||||
`trigger_manager_load_from_startup_events()` (line 1078) which does check it.
|
||||
|
||||
## Fix
|
||||
|
||||
### 1. Add a static helper: `load_adopted_d_tags()`
|
||||
|
||||
Add a static function in `trigger_manager.c` that:
|
||||
- Fetches kind 10123 events via `nostr_handler_get_self_events_by_kind_json(10123)`
|
||||
- Selects the latest event by `created_at` (same logic as agent.c)
|
||||
- Parses `"a"` tags using a local `parse_skill_address_tag_local()` equivalent
|
||||
- Populates a fixed-size array of adopted d_tag strings
|
||||
- Returns the count
|
||||
|
||||
Since both call sites only deal with self-authored skills, matching on d_tag
|
||||
alone is sufficient (the pubkey in the "a" tag will be the agent's own).
|
||||
|
||||
### 2. Add `enabled` tag check
|
||||
|
||||
Both functions should read the `enabled` tag (defaulting to `"true"`) and skip
|
||||
skills where `enabled` is `false` or `0`, matching the pattern already used in
|
||||
`trigger_manager_load_from_startup_events()`.
|
||||
|
||||
### 3. Gate `trigger_manager_load_from_skills()`
|
||||
|
||||
Before calling `trigger_manager_add()`, check:
|
||||
1. The skill's d_tag is in the adopted set
|
||||
2. The skill's `enabled` tag is not false/0
|
||||
|
||||
If the adoption list is empty or missing, log a warning and load nothing (do not
|
||||
fall back to loading all skills — that's the bug).
|
||||
|
||||
### 4. Gate `trigger_manager_reconcile_from_relays()`
|
||||
|
||||
Apply the same adoption + enabled checks. This ensures the code is correct when
|
||||
`enable_periodic_reconcile` is eventually flipped back to 1.
|
||||
|
||||
### 5. Pass `is_enabled` to `trigger_manager_add()`
|
||||
|
||||
Currently both functions pass `1` (hardcoded enabled) as the `enabled` parameter.
|
||||
Change to pass the actual `is_enabled` value derived from the `enabled` tag.
|
||||
|
||||
## Files Changed
|
||||
|
||||
- `src/trigger_manager.c` — add helper, fix both functions
|
||||
|
||||
## Risk
|
||||
|
||||
Low. The fix adds a filter before `trigger_manager_add()`. If the adoption list
|
||||
is present and correct, behavior is unchanged for adopted skills. If the adoption
|
||||
list is missing, triggers simply won't load (which is the correct behavior —
|
||||
nothing should fire if nothing is adopted).
|
||||
440
plans/nsigner_integration.md
Normal file
440
plans/nsigner_integration.md
Normal file
@@ -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 <mode>` — one of `local`, `nsigner_unix`, `nsigner_tcp`, `nsigner_serial`,
|
||||
`nsigner_fds`. Overrides `signer.mode` in config.
|
||||
- `--signer-socket <name>` — abstract socket name (nsigner_unix).
|
||||
- `--signer-role <role>` — n_signer role selector (default `main`).
|
||||
- `--signer-timeout <ms>` — per-call timeout (default 15000).
|
||||
- `--signer-tcp <host:port>` — 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, "<verb>")` 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 `<classification>: <detail> (rc=<n>)`, 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 <pinned>`
|
||||
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(<pinned name>, ...)`.
|
||||
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 `<termios.h>` — 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.
|
||||
1
schemas.json
Normal file
1
schemas.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
113
src/config.c
113
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;
|
||||
|
||||
45
src/config.h
45
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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
425
src/main.c
425
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 <mode>\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 <name>\n"
|
||||
" Abstract socket name for nsigner_unix (without leading @).\n"
|
||||
" --signer-role <role>\n"
|
||||
" n_signer role selector (default: main).\n"
|
||||
" --signer-timeout <ms>\n"
|
||||
" Per-call signer timeout in milliseconds (default: 15000).\n"
|
||||
" --signer-tcp <host:port>\n"
|
||||
" Shorthand for --signer nsigner_tcp with host/port parsed.\n"
|
||||
" --dump-schemas\n"
|
||||
" Print tool schemas JSON and exit.\n"
|
||||
" --test-tool <name> <args_json>\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 : "<empty>");
|
||||
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 <host:port> 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 : "<auto>";
|
||||
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 : "<tcp>",
|
||||
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 : "<auto>";
|
||||
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 : "<tcp>",
|
||||
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: <name> <args_json>.
|
||||
* 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 <name> <args_json> (missing both)\n");
|
||||
} else {
|
||||
fprintf(stderr, "--test-tool requires <name> <args_json> (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;
|
||||
|
||||
@@ -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 53
|
||||
#define DIDACTYL_VERSION "v0.2.53"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 : "<no-id>");
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
132
src/signer_health.c
Normal file
132
src/signer_health.c
Normal file
@@ -0,0 +1,132 @@
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "signer_health.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <pthread.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
59
src/signer_health.h
Normal file
59
src/signer_health.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#ifndef DIDACTYL_SIGNER_HEALTH_H
|
||||
#define DIDACTYL_SIGNER_HEALTH_H
|
||||
|
||||
#include <time.h>
|
||||
|
||||
/* 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 "<classification>: <detail>".
|
||||
* 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 */
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -589,6 +589,126 @@ static void parse_trigger_runtime_tags(cJSON* tags,
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Load the set of adopted skill d_tags from the kind 10123 adoption list.
|
||||
*
|
||||
* Fetches the agent's latest kind 10123 event from cache, parses the "a" tags,
|
||||
* and populates out_d_tags with the d_tag portion of each adopted skill address.
|
||||
*
|
||||
* Since both trigger load paths only deal with self-authored skills, matching on
|
||||
* d_tag alone is sufficient — the pubkey in the "a" tag will be the agent's own.
|
||||
*
|
||||
* Returns the number of adopted d_tags, or -1 on error.
|
||||
*/
|
||||
#define TRIGGER_ADOPTED_D_TAGS_MAX 64
|
||||
static int load_adopted_d_tags(trigger_manager_t* mgr,
|
||||
char out_d_tags[][65],
|
||||
int max_d_tags) {
|
||||
if (!mgr || !mgr->cfg || !out_d_tags || max_d_tags <= 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
|
||||
char* adoption_json = nostr_handler_get_self_events_by_kind_json(10123);
|
||||
if (!adoption_json) {
|
||||
DEBUG_WARN("[didactyl] trigger adoption check: no kind 10123 event in cache");
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* adoption_events = cJSON_Parse(adoption_json);
|
||||
free(adoption_json);
|
||||
if (!adoption_events || !cJSON_IsArray(adoption_events) ||
|
||||
cJSON_GetArraySize(adoption_events) <= 0) {
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_WARN("[didactyl] trigger adoption check: kind 10123 parse failed or empty");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Find the latest adoption list event by created_at */
|
||||
cJSON* list_event = NULL;
|
||||
double list_event_created_at = -1.0;
|
||||
int adoption_n = cJSON_GetArraySize(adoption_events);
|
||||
for (int ai = 0; ai < adoption_n; ai++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(adoption_events, ai);
|
||||
if (!ev || !cJSON_IsObject(ev)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* ev_pubkey = cJSON_GetObjectItemCaseSensitive(ev, "pubkey");
|
||||
if (!ev_pubkey || !cJSON_IsString(ev_pubkey) || !ev_pubkey->valuestring ||
|
||||
strcmp(ev_pubkey->valuestring, mgr->cfg->keys.public_key_hex) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
double created = (created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0.0;
|
||||
if (!list_event || created > list_event_created_at) {
|
||||
list_event = ev;
|
||||
list_event_created_at = created;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
if (!list_tags || !cJSON_IsArray(list_tags)) {
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger adoption check: no tags in adoption list event");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int tn = cJSON_GetArraySize(list_tags);
|
||||
for (int i = 0; i < tn && count < max_d_tags; i++) {
|
||||
cJSON* tag = cJSON_GetArrayItem(list_tags, i);
|
||||
if (!tag || !cJSON_IsArray(tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* key = cJSON_GetArrayItem(tag, 0);
|
||||
cJSON* val = cJSON_GetArrayItem(tag, 1);
|
||||
if (!key || !val || !cJSON_IsString(key) || !cJSON_IsString(val) ||
|
||||
!key->valuestring || !val->valuestring ||
|
||||
strcmp(key->valuestring, "a") != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Parse "kind:pubkey:d_tag" — we only need the d_tag (3rd field) */
|
||||
const char* p1 = strchr(val->valuestring, ':');
|
||||
if (!p1) continue;
|
||||
const char* p2 = strchr(p1 + 1, ':');
|
||||
if (!p2) continue;
|
||||
|
||||
const char* d_tag_start = p2 + 1;
|
||||
size_t d_tag_len = strlen(d_tag_start);
|
||||
if (d_tag_len == 0 || d_tag_len >= 65U) {
|
||||
continue;
|
||||
}
|
||||
|
||||
snprintf(out_d_tags[count], 65, "%s", d_tag_start);
|
||||
count++;
|
||||
}
|
||||
|
||||
cJSON_Delete(adoption_events);
|
||||
DEBUG_INFO("[didactyl] trigger adoption check: %d adopted skill(s) found", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether d_tag appears in the adopted_d_tags array.
|
||||
*/
|
||||
static int is_d_tag_adopted(const char* d_tag,
|
||||
char adopted_d_tags[][65],
|
||||
int adopted_count) {
|
||||
if (!d_tag || d_tag[0] == '\0' || !adopted_d_tags || adopted_count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
for (int i = 0; i < adopted_count; i++) {
|
||||
if (strcmp(adopted_d_tags[i], d_tag) == 0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
@@ -628,6 +748,18 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
|
||||
int considered = 0;
|
||||
int loaded = 0;
|
||||
int skipped_not_adopted = 0;
|
||||
int skipped_disabled = 0;
|
||||
|
||||
/* Load adopted d_tags from kind 10123 adoption list */
|
||||
char adopted_d_tags[TRIGGER_ADOPTED_D_TAGS_MAX][65];
|
||||
memset(adopted_d_tags, 0, sizeof(adopted_d_tags));
|
||||
int adopted_count = load_adopted_d_tags(mgr, adopted_d_tags, TRIGGER_ADOPTED_D_TAGS_MAX);
|
||||
if (adopted_count <= 0) {
|
||||
DEBUG_WARN("[didactyl] trigger reconcile: no adopted skills found — skipping all trigger registration");
|
||||
cJSON_Delete(events);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int event_count = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < event_count; i++) {
|
||||
@@ -642,17 +774,25 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter_j = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter_j && cJSON_IsString(filter_j) && filter_j->valuestring) ? filter_j->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
considered++;
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Adoption gate: only register triggers for adopted skills */
|
||||
if (!is_d_tag_adopted(d_tag, adopted_d_tags, adopted_count)) {
|
||||
skipped_not_adopted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
@@ -663,6 +803,13 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Enabled gate: skip skills explicitly disabled */
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (!is_enabled) {
|
||||
skipped_disabled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(action_s, "template") == 0) {
|
||||
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
|
||||
}
|
||||
@@ -691,7 +838,7 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
filter_s,
|
||||
TRIGGER_ACTION_LLM,
|
||||
trigger_s,
|
||||
1,
|
||||
is_enabled,
|
||||
llm_s,
|
||||
tools_s,
|
||||
has_max_tokens,
|
||||
@@ -705,7 +852,8 @@ static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
DEBUG_INFO("[didactyl] trigger reconcile complete: loaded=%d considered=%d", loaded, considered);
|
||||
DEBUG_INFO("[didactyl] trigger reconcile complete: loaded=%d considered=%d skipped_not_adopted=%d skipped_disabled=%d",
|
||||
loaded, considered, skipped_not_adopted, skipped_disabled);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -943,8 +1091,19 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
|
||||
int loaded = 0;
|
||||
int considered = 0;
|
||||
int skipped_not_adopted = 0;
|
||||
int skipped_disabled = 0;
|
||||
const int kinds[2] = {31123, 31124};
|
||||
|
||||
/* Load adopted d_tags from kind 10123 adoption list */
|
||||
char adopted_d_tags[TRIGGER_ADOPTED_D_TAGS_MAX][65];
|
||||
memset(adopted_d_tags, 0, sizeof(adopted_d_tags));
|
||||
int adopted_count = load_adopted_d_tags(mgr, adopted_d_tags, TRIGGER_ADOPTED_D_TAGS_MAX);
|
||||
if (adopted_count <= 0) {
|
||||
DEBUG_WARN("[didactyl] trigger load from skills: no adopted skills found — skipping all trigger registration");
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int k = 0; k < 2; k++) {
|
||||
char* skill_json = nostr_handler_get_self_events_by_kind_json(kinds[k]);
|
||||
if (!skill_json) {
|
||||
@@ -971,17 +1130,25 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
cJSON* enabled = find_tag_value_string(tags, "enabled");
|
||||
|
||||
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter && cJSON_IsString(filter) && filter->valuestring) ? filter->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
const char* enabled_s = (enabled && cJSON_IsString(enabled) && enabled->valuestring) ? enabled->valuestring : "true";
|
||||
|
||||
considered++;
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Adoption gate: only register triggers for adopted skills */
|
||||
if (!is_d_tag_adopted(d_tag, adopted_d_tags, adopted_count)) {
|
||||
skipped_not_adopted++;
|
||||
continue;
|
||||
}
|
||||
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
@@ -992,6 +1159,13 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/* Enabled gate: skip skills explicitly disabled */
|
||||
int is_enabled = (strcmp(enabled_s, "false") == 0 || strcmp(enabled_s, "0") == 0) ? 0 : 1;
|
||||
if (!is_enabled) {
|
||||
skipped_disabled++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(action_s, "template") == 0) {
|
||||
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
|
||||
}
|
||||
@@ -1020,7 +1194,7 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
filter_s,
|
||||
TRIGGER_ACTION_LLM,
|
||||
trigger_s,
|
||||
1,
|
||||
is_enabled,
|
||||
llm_s,
|
||||
tools_s,
|
||||
has_max_tokens,
|
||||
@@ -1036,7 +1210,8 @@ int trigger_manager_load_from_skills(trigger_manager_t* mgr) {
|
||||
cJSON_Delete(skill_events);
|
||||
}
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d)", loaded, considered);
|
||||
DEBUG_INFO("[didactyl] trigger manager loaded %d trigger(s) from self skills (considered=%d skipped_not_adopted=%d skipped_disabled=%d)",
|
||||
loaded, considered, skipped_not_adopted, skipped_disabled);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
70
test_output.log
Normal file
70
test_output.log
Normal file
@@ -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>
|
||||
Path to config/genesis JSONC file (default: ./genesis.jsonc).
|
||||
If file is missing, startup falls back to minimal nsec-only mode.
|
||||
--nsec <nsec_or_hex>
|
||||
Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.
|
||||
--admin <npub_or_hex>
|
||||
Administrator pubkey override; accepts npub1... or 64-char hex.
|
||||
--api-port <port>
|
||||
Enable HTTP API and bind to this port (1-65535).
|
||||
--api-bind <addr>
|
||||
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 <mode>
|
||||
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 <name>
|
||||
Abstract socket name for nsigner_unix (without leading @).
|
||||
--signer-role <role>
|
||||
n_signer role selector (default: main).
|
||||
--signer-timeout <ms>
|
||||
Per-call signer timeout in milliseconds (default: 15000).
|
||||
--signer-tcp <host:port>
|
||||
Shorthand for --signer nsigner_tcp with host/port parsed.
|
||||
--dump-schemas
|
||||
Print tool schemas JSON and exit.
|
||||
--test-tool <name> <args_json>
|
||||
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"}'
|
||||
1
tests/results/20260708T142949Z/agent_debug.log
Normal file
1
tests/results/20260708T142949Z/agent_debug.log
Normal file
@@ -0,0 +1 @@
|
||||
[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting
|
||||
59
tests/results/20260708T142949Z/runtime_test_genesis.jsonc
Normal file
59
tests/results/20260708T142949Z/runtime_test_genesis.jsonc
Normal file
@@ -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\"}"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1
tests/results/20260708T143054Z/agent_debug.log
Normal file
1
tests/results/20260708T143054Z/agent_debug.log
Normal file
@@ -0,0 +1 @@
|
||||
[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting
|
||||
59
tests/results/20260708T143054Z/runtime_test_genesis.jsonc
Normal file
59
tests/results/20260708T143054Z/runtime_test_genesis.jsonc
Normal file
@@ -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\"}"]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user