Compare commits

...

2 Commits

41 changed files with 3064 additions and 148 deletions

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.50
## Current Status — v0.2.52
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.50 — Fix startup step-14 false-negative by adding sync self-skill retry validation and post-auth query REQ resend
> Last release update: v0.2.52 — Fix trigger manager bypassing skill adoption list — cron/webhook triggers now only fire for adopted and enabled skills
- 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,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");
@@ -1826,6 +2163,7 @@ int main(int argc, char** argv) {
startup_step_ok(16, "Subscribe wallet events", NULL);
startup_step_begin(17, "Initialize cashu wallet");
cashu_wallet_set_signer(g_signer);
if (cashu_wallet_init(&cfg) != 0) {
DEBUG_WARN("[didactyl] startup phase: cashu_wallet_init failed (continuing without wallet)");
startup_step_fail(17, "Initialize cashu wallet", "cashu_wallet_init failed; wallet tools may return errors");
@@ -1955,6 +2293,7 @@ int main(int argc, char** argv) {
nostr_handler_cleanup();
llm_cleanup();
config_free(&cfg);
nostr_signer_free(g_signer);
nostr_cleanup();
return 0;

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 50
#define DIDACTYL_VERSION "v0.2.50"
#define DIDACTYL_VERSION_PATCH 52
#define DIDACTYL_VERSION "v0.2.52"
// 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\"}"]
]
}
]
}