Compare commits

...

4 Commits

43 changed files with 3301 additions and 186 deletions

View File

@@ -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 \

View File

@@ -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) \

View File

@@ -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.49
## Current Status — v0.2.53
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.49 — Add startup-failure DM de-dup with marker, relay-state/needed-event diagnostics, and clear-on-READY rearm
> 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
View File

@@ -0,0 +1 @@
131350

70
didactyl_nsigner.log Normal file
View 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
View File

@@ -0,0 +1 @@
1

70
help_output.txt Normal file
View 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

Submodule nostr_core_lib.old added at af06f23939

668
nsigner.log Normal file
View 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

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
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
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
(none)

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
(none)

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120741 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON
Roles
Role Purpose Curve Selector
-------------------- ------------ ------------ ------------
main nostr secp256k1 nostr_index
Activity (latest first)
0708-120741 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120623 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120609 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120554 uid:1000 get_public_key(main) ALLOWED:prompt
0708-120532 uid:1000 get_public_key(main) ALLOWED:prompt

================================================================================
n_signer v0.0.33
================================================================================
> Main Menu
Connections
listen: unix @didactyl_test_signer
client: nsigner --socket-name didactyl_test_signer client '<json>'
fips ipv6: fdb8:36ad:c97a:44a9:e70e:e591:5d53:8f28
fips npub: npub10vt4scusw6lq27qw83nfwp5sqer492h0tnwa8ugqjvp6l4xuz2qsdycrwd
q/x quit
l lock/reunlock
r refresh
A toggle auto-approve
session=unlocked (12 words) signer=didactyl_test_signer derived=1 auto-approve=ON

1
nsigner.pid Normal file
View File

@@ -0,0 +1 @@
131270

View File

@@ -0,0 +1,148 @@
# Fix: Self-Skill Startup Race (step 14 false-negative)
## Symptom
`didactyl.service` / `simon.service` intermittently fail startup at step 14:
```
startup checklist [14] Subscribe self-skill cache: failed (no skill events found after EOSE)
```
…even though the required events (kind 31123/31124 skills and kind 10123 adoption list)
demonstrably exist on `relay.laantungir.net` (verified with `nak`, returned in well under
a second). On other restarts the identical setup succeeds: `ok (skills=3 adoptions=1)`.
The failure is timing-dependent.
## How startup relay polling works (mechanics)
- Each startup step opens its **own** subscription (its own `REQ` + filter). Step 14's
filter is built in [`nostr_handler_subscribe_self_skills()`](src/nostr_handler.c:2800):
`{"kinds":[31123,31124,10123],"authors":[<agent_pubkey>],"limit":300}`.
- That subscription is sent to **all** configured relays in parallel inside
[`nostr_relay_pool_subscribe()`](nostr_core_lib/nostr_core/core_relay_pool.c:967).
- This is the **async/streaming** path: events arrive over time via
[`on_self_skill_event()`](src/nostr_handler.c:2478) which upserts into the shared cache
`g_self_skill_events`, and an EOSE-complete callback
([`on_self_skill_eose`](src/nostr_handler.c:2066)) flips `g_self_skill_eose_received`.
- Step 14 then immediately runs
[`nostr_handler_validate_self_skill_cache()`](src/nostr_handler.c:3089) against whatever
is in the cache at that instant.
## Root cause
The async EOSE-complete signal is decoupled from "did the relay that actually has the
events get queried and deliver yet." Two independent contributing factors:
1. **Relay connection timing.** The self-skill subscription is registered shortly after
startup. If `relay.laantungir.net` is not yet fully connected when the subscription's
completion is computed, the empty relays (`damus`, `primal`) EOSE almost instantly and
the per-relay **timeout fallback**
([`core_relay_pool.c:2128`](nostr_core_lib/nostr_core/core_relay_pool.c:2128)) marks the
remaining relay "done" after `relay_timeout_seconds` (=12) without ever receiving its
events. EOSE-complete fires with an empty cache → validation fails.
2. **Validation fires the instant completion is signaled**, with no re-check, so any race
where laantungir's events land microseconds later is lost.
This matches the `<1s` `nak` observation: when laantungir is connected and queried, it
answers almost instantly; the failures are the cycles where it wasn't
connected/queried/processed before the async completion fired.
### Why FIRST → FULL_SET alone does NOT fix it
`maybe_complete_subscription_eose()` waits for **all** relays in both modes; the only
difference is the payload passed to the callback. The cache is filled by the EVENT
callback, not the EOSE payload. Changing the mode changes neither connection timing nor
when events are available.
## Fix: use the synchronous query for the startup self-skill load
There is already a correct, synchronous "collect all events until EOSE/timeout, then
return them" primitive used elsewhere at startup (e.g. kind-10002 discovery):
- [`nostr_handler_query_json()`](src/nostr_handler.c:3395) →
[`nostr_relay_pool_query_sync()`](nostr_core_lib/nostr_core/core_relay_pool.c:1340).
- A self-skill-specific wrapper already exists and reuses it:
[`nostr_handler_refresh_self_skill_cache_from_relays()`](src/nostr_handler.c:3751),
which queries kinds 31123/31124/10123 for the agent author (and also pulls adopted
skills), populating the cache synchronously.
The synchronous query is the architecturally correct tool for "give me the complete
current set before proceeding," which is exactly what step 14 needs. It also drives the
poll/receive loop itself
([`core_relay_pool.c:1405`](nostr_core_lib/nostr_core/core_relay_pool.c:1405)) until all
connected relays EOSE or the timeout elapses.
### Important caveat to handle: connection readiness
`nostr_relay_pool_query_sync()` only queries relays that are **connected at call time**
(`if (connected_count == 0) return NULL;` at
[`core_relay_pool.c:1392`](nostr_core_lib/nostr_core/core_relay_pool.c:1392)). If
`relay.laantungir.net` is still mid-connect when step 14 runs, a sync query could still
miss it. Therefore the fix must ensure the relay holding the events is connected (or wait
briefly for it) before/within the query.
### Plan
1. **Replace the step-14 async wait+validate with a synchronous load.** In
[`src/main.c`](src/main.c) step 14 (~line 1518), instead of relying solely on
`nostr_handler_wait_for_self_skill_eose()` + an immediate
`nostr_handler_validate_self_skill_cache()`:
- Keep registering the live subscription (so ongoing skill updates still stream in via
`on_self_skill_event`) — this is still wanted for runtime.
- For the startup *gate*, call
[`nostr_handler_refresh_self_skill_cache_from_relays(timeout_ms)`](src/nostr_handler.c:3751)
with a sensible timeout (e.g. 800010000 ms). This performs the synchronous,
connection-aware query and fills `g_self_skill_events`.
- Then run `nostr_handler_validate_self_skill_cache(&skills, &adoptions)`.
2. **Wait for the self relay (laantungir) to be connected before the sync query.** Reuse
the existing relay-readiness helper pattern (`wait_for_connected_relays`) — but ensure
we wait specifically until the relay list discovered from kind 10002 (which includes
`relay.laantungir.net`) is connected, not just "any relay." If we already have a
connected-count wait, extend/bound it so the relay that owns the events has a fair
chance to connect before the sync query runs.
3. **Bounded retry around the sync query.** If the first sync query returns an empty cache,
retry the sync query a small, bounded number of times (e.g. up to ~58s total),
pumping `nostr_handler_poll()` between attempts to let connections settle. Only fail
step 14 after the bounded retries still yield an empty cache. This converts a transient
connection-timing miss into a brief wait instead of a hard failure.
4. **Keep the async subscription for runtime updates.** Do not remove the live
subscription; it's still responsible for picking up skill changes after startup. Only
the *startup gate* switches to the synchronous load for determinism.
### Why this is the right layer
- It uses the library's intended synchronous collect-all primitive rather than racing a
streaming subscription against an immediate validation.
- It is connection-aware and bounded, so a slow/late relay connect becomes a short wait,
not a false "no skill events" failure.
- It does not require modifying `nostr_core_lib` (though if desired, a follow-up could add
a `query_sync` variant that waits for a named relay to connect; not required here).
## Interaction with the failure-DM throttle (already shipped)
- On success, the READY path clears the throttle marker
([`src/main.c`](src/main.c) `startup_failure_marker_clear`). No change needed.
- After this fix, step-14 failures should be genuinely rare (real outage / events truly
absent), and the (single, throttled) failure DM will carry relay states + needed event
kinds for accurate diagnosis.
## Verification
1. Build (`make`) clean, then `build_static.sh`.
2. Deploy to VM410; restart both services many times in a row and confirm step 14 reaches
`ok (skills=N adoptions=M)` **consistently** (no intermittent false negatives).
3. Inspect logs to confirm the synchronous query path is used and that, on a cold cycle,
it waits for `relay.laantungir.net` to connect and then returns the events.
4. Negative test: configure only empty relays (no skill events anywhere) and confirm step
14 still fails after the bounded retries (no false positive), sending exactly one
throttled failure DM.
5. Confirm a normal successful startup still sends the startup DM and clears the marker.
## Out of scope (possible follow-ups)
- Adding a `nostr_core_lib` `query_sync` variant that explicitly waits for specific
relays to connect before issuing the REQ.
- Softening step 14 into a degraded-start mode.

View 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).

View 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 (M1M6 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 1820) — 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

File diff suppressed because one or more lines are too long

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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());

View File

@@ -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");
@@ -1683,50 +2020,101 @@ int main(int argc, char** argv) {
return 1;
}
const int self_skill_eose_timeout_ms = 15000;
if (nostr_handler_wait_for_self_skill_eose(self_skill_eose_timeout_ms) != 0) {
startup_step_fail(14,
"Subscribe self-skill cache",
"self-skill EOSE not received within timeout");
notify_admin_startup_failure(&cfg,
14,
"Subscribe self-skill cache",
"self-skill EOSE not received within timeout");
fprintf(stderr,
"Startup aborted: self-skill EOSE not received within %d ms\n",
self_skill_eose_timeout_ms);
fprintf(stderr,
"Check relay connectivity (connected=%d/%d) and ensure skill events exist on relays\n",
nostr_handler_connected_relay_count(),
cfg.relay_count);
fprintf(stderr,
"If this is a first run, use genesis to publish startup events\n");
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_cleanup();
return 1;
}
const int self_skill_sync_attempt_timeout_ms = 4000;
const int self_skill_sync_total_budget_ms = 15000;
const int self_skill_retry_step_ms = 250;
int self_skill_waited_ms = 0;
int self_skill_refresh_attempts = 0;
int self_skill_refresh_rc = -1;
int self_skill_validate_rc = -1;
int startup_skill_count = 0;
int startup_adoption_count = 0;
if (nostr_handler_validate_self_skill_cache(&startup_skill_count, &startup_adoption_count) != 0) {
while (self_skill_waited_ms <= self_skill_sync_total_budget_ms) {
int connected_relays = nostr_handler_connected_relay_count();
if (connected_relays > 0) {
self_skill_refresh_attempts++;
self_skill_refresh_rc =
nostr_handler_refresh_self_skill_cache_from_relays(self_skill_sync_attempt_timeout_ms);
if (self_skill_refresh_rc == 0) {
self_skill_validate_rc =
nostr_handler_validate_self_skill_cache(&startup_skill_count, &startup_adoption_count);
if (self_skill_validate_rc == 0) {
DEBUG_INFO("[didactyl] startup phase: self-skill sync+validate succeeded (attempt=%d connected=%d/%d skills=%d adoptions=%d)",
self_skill_refresh_attempts,
connected_relays,
cfg.relay_count,
startup_skill_count,
startup_adoption_count);
break;
}
DEBUG_WARN("[didactyl] startup phase: self-skill sync returned no skill events yet (attempt=%d connected=%d/%d)",
self_skill_refresh_attempts,
connected_relays,
cfg.relay_count);
} else {
DEBUG_WARN("[didactyl] startup phase: self-skill sync refresh failed (attempt=%d connected=%d/%d)",
self_skill_refresh_attempts,
connected_relays,
cfg.relay_count);
}
} else {
DEBUG_INFO("[didactyl] startup phase: waiting for connected relay before self-skill sync refresh");
}
if (self_skill_waited_ms >= self_skill_sync_total_budget_ms) {
break;
}
int step_ms = self_skill_retry_step_ms;
if (self_skill_waited_ms + step_ms > self_skill_sync_total_budget_ms) {
step_ms = self_skill_sync_total_budget_ms - self_skill_waited_ms;
}
if (step_ms <= 0) {
break;
}
(void)nostr_handler_poll(step_ms);
self_skill_waited_ms += step_ms;
}
if (self_skill_validate_rc != 0) {
const char* fail_detail = (self_skill_refresh_rc == 0)
? "no skill events found after sync query retries"
: "self-skill sync query failed within timeout";
startup_step_fail(14,
"Subscribe self-skill cache",
"no skill events found after EOSE");
fail_detail);
notify_admin_startup_failure(&cfg,
14,
"Subscribe self-skill cache",
"no skill events found after EOSE");
fprintf(stderr,
"Startup aborted: no skill events (kind 31123/31124) found in self-skill cache\n");
fprintf(stderr,
"The agent requires at least one skill event to operate\n");
fprintf(stderr,
"Run genesis to publish the default skill, or publish a skill manually\n");
fail_detail);
if (self_skill_refresh_rc == 0) {
fprintf(stderr,
"Startup aborted: no skill events (kind 31123/31124) found in self-skill cache after sync query retries\n");
fprintf(stderr,
"Attempts=%d connected=%d/%d\n",
self_skill_refresh_attempts,
nostr_handler_connected_relay_count(),
cfg.relay_count);
fprintf(stderr,
"The agent requires at least one skill event to operate\n");
fprintf(stderr,
"Run genesis to publish the default skill, or publish a skill manually\n");
} else {
fprintf(stderr,
"Startup aborted: failed to sync self-skill cache from connected relays within %d ms\n",
self_skill_sync_total_budget_ms);
fprintf(stderr,
"Attempts=%d connected=%d/%d\n",
self_skill_refresh_attempts,
nostr_handler_connected_relay_count(),
cfg.relay_count);
fprintf(stderr,
"Check relay connectivity and ensure required self-skill events exist on at least one connected relay\n");
}
agent_cleanup();
nostr_block_list_cleanup();
trigger_manager_cleanup(&trigger_manager);
@@ -1775,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");
@@ -1904,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;

View File

@@ -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 49
#define DIDACTYL_VERSION "v0.2.49"
#define DIDACTYL_VERSION_PATCH 53
#define DIDACTYL_VERSION "v0.2.53"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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));

View File

@@ -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);

View File

@@ -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
View 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
View 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 */

View File

@@ -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");

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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));

View File

@@ -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
View 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"}'

View File

@@ -0,0 +1 @@
[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting

View 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\"}"]
]
}
]
}

View File

@@ -0,0 +1 @@
[2026-07-08 10:31:28] [INFO ] [main.c:1476] Didactyl v0.2.50 starting

View 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\"}"]
]
}
]
}