v0.2.55 - Fix wizard quit bug, add nostr_index selector, fix relay flapping backoff defeat, add NOSTR_ENABLE_NSIGNER_CLIENT to Dockerfile

This commit is contained in:
Didactyl User
2026-07-30 18:16:06 -04:00
parent 37a6f4ea75
commit ec4a170682
7 changed files with 121 additions and 38 deletions
+1
View File
@@ -112,6 +112,7 @@ RUN NOSTR_LIB=$(ls /build/nostr_core_lib/libnostr_core_*.a 2>/dev/null | head -1
OPENSSL_LIBS="$(pkg-config --static --libs openssl)" && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-D_GNU_SOURCE -D_DEFAULT_SOURCE -D_POSIX_C_SOURCE=200809L -DMG_TLS=MG_TLS_BUILTIN \
-DNOSTR_ENABLE_NSIGNER_CLIENT=1 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Isrc -Isrc/tools -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
+2 -2
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.54
## Current Status — v0.2.55
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.2.54 — Full n_signer transport support in setup wizard: added nsigner_serial, nsigner_fds, and nsigner_qrexec (Qubes cross-qube) modes. Updated nostr_core_lib to v0.6.10 (qrexec transport). Extended signer_config_t with serial_device, fds_read_fd/fds_write_fd, target_qube, service_name fields. Added --signer-serial, --signer-fds, --signer-qrexec, --signer-service CLI flags. Wizard transport picker now offers all 5 remote transports with auto-discovery for unix/serial. Existing-agent flow supports signer-based identity (no nsec required) with NIP-44 decrypt/encrypt routed through signer verbs. Systemd install supports serial/qrexec; fds rejected with clear message. Connectivity-check errors now pause for Enter before re-rendering. Deleted nostr_core_lib.old. Added docs/SIGNER.md.
> Last release update: v0.2.55 — Fix wizard quit bug, add nostr_index selector, fix relay flapping backoff defeat, add NOSTR_ENABLE_NSIGNER_CLIENT to Dockerfile
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
+11
View File
@@ -280,6 +280,16 @@ static int parse_signer_config(cJSON* root, didactyl_config_t* config) {
}
}
/* Optional nostr_index: numeric key index selector. When set (>= 0) it
* overrides role on the n_signer side. -1 (default) means use role. */
cJSON* nostr_index = cJSON_GetObjectItemCaseSensitive(signer, "nostr_index");
if (nostr_index && cJSON_IsNumber(nostr_index)) {
int idx = (int)nostr_index->valuedouble;
if (idx >= 0) {
config->signer.nostr_index = idx;
}
}
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");
@@ -1615,6 +1625,7 @@ int config_load(const char* path, didactyl_config_t* config) {
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.nostr_index = -1; /* -1 = unset; use role selector */
config->signer.timeout_ms = 15000;
config->signer.auth_privkey_hex[0] = '\0';
config->signer.tcp_host[0] = '\0';
+1
View File
@@ -149,6 +149,7 @@ typedef struct {
char mode[OW_MAX_SIGNER_MODE_LEN]; /* "local" | "nsigner_unix" | "nsigner_tcp" | "nsigner_serial" | "nsigner_fds" | "nsigner_qrexec" */
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 nostr_index; /* n_signer key index selector (-1 = unset; use role). When set, overrides role. */
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) */
+21 -2
View File
@@ -188,6 +188,8 @@ static void print_usage(const char* prog) {
" qube name (service defaults to qubes.NsignerRpc).\n"
" --signer-service <name>\n"
" qrexec service name for nsigner_qrexec (default: qubes.NsignerRpc).\n"
" --signer-index <n>\n"
" n_signer key index selector (overrides --signer-role). -1 = use role.\n"
" --dump-schemas\n"
" Print tool schemas JSON and exit.\n"
" --test-tool <name> <args_json>\n"
@@ -308,7 +310,8 @@ static int apply_signer_overrides(didactyl_config_t* cfg,
const char* cli_serial,
const char* cli_fds,
const char* cli_qrexec,
const char* cli_service) {
const char* cli_service,
int cli_index) {
if (!cfg) {
return -1;
}
@@ -397,6 +400,11 @@ static int apply_signer_overrides(didactyl_config_t* cfg,
if (cli_timeout > 0) {
cfg->signer.timeout_ms = cli_timeout;
}
/* --signer-index overrides the genesis nostr_index. -1 means unset (use role).
* Any non-negative value selects a specific key index on the n_signer side. */
if (cli_index >= 0) {
cfg->signer.nostr_index = cli_index;
}
return 0;
}
@@ -502,6 +510,14 @@ static nostr_signer_t* construct_signer(didactyl_config_t* cfg) {
return NULL;
}
/* Apply nostr_index selector when set (overrides role on the n_signer side). */
if (signer && cfg->signer.nostr_index >= 0) {
if (nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index) != NOSTR_SUCCESS) {
fprintf(stderr, "Warning: failed to set nostr_index=%d on signer (mode=%s)\n",
cfg->signer.nostr_index, mode);
}
}
signer_health_set_mode(mode);
if (!signer) {
const char* sock = cfg->signer.socket_name[0] ? cfg->signer.socket_name : "<auto>";
@@ -1586,6 +1602,7 @@ int main(int argc, char** argv) {
const char* cli_signer_fds = NULL; /* read_fd:write_fd shorthand */
const char* cli_signer_qrexec = NULL; /* target qube shorthand */
const char* cli_signer_service = NULL; /* qrexec service name */
int cli_signer_index = -1; /* n_signer key index (-1 = unset) */
didactyl_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
nostr_signer_t* g_signer = NULL;
@@ -1668,6 +1685,8 @@ int main(int argc, char** argv) {
cli_signer_qrexec = argv[++i];
} else if (strcmp(argv[i], "--signer-service") == 0 && i + 1 < argc) {
cli_signer_service = argv[++i];
} else if (strcmp(argv[i], "--signer-index") == 0 && i + 1 < argc) {
cli_signer_index = atoi(argv[++i]);
} else if (strcmp(argv[i], "--dump-schemas") == 0) {
dump_schemas = 1;
} else if (strcmp(argv[i], "--test-tool") == 0 && i + 2 < argc) {
@@ -1718,7 +1737,7 @@ int main(int argc, char** argv) {
cli_signer_role, cli_signer_timeout,
cli_signer_tcp, cli_signer_serial,
cli_signer_fds, cli_signer_qrexec,
cli_signer_service) != 0) {
cli_signer_service, cli_signer_index) != 0) {
config_free(&cfg);
nostr_cleanup();
return 1;
+2 -2
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 54
#define DIDACTYL_VERSION "v0.2.54"
#define DIDACTYL_VERSION_PATCH 55
#define DIDACTYL_VERSION "v0.2.55"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+83 -32
View File
@@ -95,9 +95,15 @@ static int derive_keys_from_nsec_local(const char* nsec_or_hex, didactyl_config_
static int line_is_quit(const char* s) {
if (!s) return 0;
/* Trim leading whitespace. */
while (*s && isspace((unsigned char)*s)) s++;
/* Require an exact match of "q" or "x" (case-insensitive), so values
* like "qubes.NsignerRpc" or "x25519..." are not mistaken for quit. */
char c = (char)tolower((unsigned char)*s);
return c == 'q' || c == 'x';
if (c != 'q' && c != 'x') return 0;
/* The next character must be end-of-string or whitespace. */
if (s[1] != '\0' && !isspace((unsigned char)s[1])) return 0;
return 1;
}
static void trim_line(char* s) {
@@ -325,6 +331,7 @@ static void config_set_defaults(didactyl_config_t* cfg) {
* 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.nostr_index = -1; /* -1 = unset; use role selector */
cfg->signer.timeout_ms = 15000;
}
@@ -1274,6 +1281,9 @@ static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg,
cJSON_AddStringToObject(signer_obj, "mode", cfg->signer.mode);
cJSON_AddStringToObject(signer_obj, "socket_name", cfg->signer.socket_name);
cJSON_AddStringToObject(signer_obj, "role", cfg->signer.role);
if (cfg->signer.nostr_index >= 0) {
cJSON_AddNumberToObject(signer_obj, "nostr_index", cfg->signer.nostr_index);
}
cJSON_AddNumberToObject(signer_obj, "timeout_ms", cfg->signer.timeout_ms);
cJSON_AddItemToObject(user_settings, "signer", signer_obj);
}
@@ -2095,35 +2105,45 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c
* runtime-only); rejected by the caller before reaching here.
* The agent pubkey, admin, LLM and relays are recovered from Nostr at boot. */
char key_args[768] = {0};
/* Build the optional --signer-index tail once; appended to all remote modes. */
char index_tail[32] = {0};
if (cfg->signer.nostr_index >= 0) {
snprintf(index_tail, sizeof(index_tail), " --signer-index %d", cfg->signer.nostr_index);
}
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",
"--signer nsigner_unix --signer-role %s --signer-timeout %d%s%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 : "");
cfg->signer.socket_name[0] ? cfg->signer.socket_name : "",
index_tail);
} 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",
"--signer-tcp %s --signer-role %s --signer-timeout %d%s",
host_port,
cfg->signer.role[0] ? cfg->signer.role : "main",
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000);
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000,
index_tail);
} else if (strcmp(cfg->signer.mode, "nsigner_serial") == 0) {
snprintf(key_args, sizeof(key_args),
"--signer nsigner_serial --signer-serial %s --signer-role %s --signer-timeout %d",
"--signer nsigner_serial --signer-serial %s --signer-role %s --signer-timeout %d%s",
cfg->signer.serial_device,
cfg->signer.role[0] ? cfg->signer.role : "main",
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000);
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000,
index_tail);
} else if (strcmp(cfg->signer.mode, "nsigner_qrexec") == 0) {
const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc";
snprintf(key_args, sizeof(key_args),
"--signer nsigner_qrexec --signer-qrexec %s --signer-service %s --signer-role %s --signer-timeout %d",
"--signer nsigner_qrexec --signer-qrexec %s --signer-service %s --signer-role %s --signer-timeout %d%s",
cfg->signer.target_qube,
svc,
cfg->signer.role[0] ? cfg->signer.role : "main",
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000);
cfg->signer.timeout_ms > 0 ? cfg->signer.timeout_ms : 15000,
index_tail);
} else if (strcmp(cfg->signer.mode, "nsigner_fds") == 0) {
/* Defensive: callers reject this before install, but guard anyway. */
fprintf(stderr, "%snsigner_fds mode cannot be installed as a systemd service "
@@ -2292,6 +2312,11 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign
char c = read_menu_choice(opts, 7);
if (c == 'q') return -1;
if (c == 'b') return 1;
if (c == '\0') {
fprintf(stderr, "%sUnknown option. Please pick one of u/t/s/f/e/b/q.%s\n",
ANSI_RED, ANSI_RESET);
continue;
}
char chosen_mode[OW_MAX_SIGNER_MODE_LEN] = {0};
@@ -2412,24 +2437,26 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign
if (c == 'e') {
snprintf(chosen_mode, sizeof(chosen_mode), "%s", "nsigner_qrexec");
char qube[WIZARD_LINE_MAX] = {0};
if (read_line_prompt(" Target qube (e.g. nostr_signer): ", qube, sizeof(qube)) != 0) return -1;
if (read_line_prompt(" Target qube [nostr_signer]: ", qube, sizeof(qube)) != 0) return -1;
if (line_is_quit(qube)) return -1;
if (qube[0] == '\0') {
fprintf(stderr, "%sA target qube name is required.%s\n", ANSI_RED, ANSI_RESET);
continue;
}
snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", qube);
snprintf(cfg->signer.target_qube, sizeof(cfg->signer.target_qube), "%s", qube[0] ? qube : "nostr_signer");
char svc[WIZARD_LINE_MAX] = {0};
if (read_line_prompt(" qrexec service [qubes.NsignerRpc]: ", svc, sizeof(svc)) != 0) return -1;
if (line_is_quit(svc)) return -1;
snprintf(cfg->signer.service_name, sizeof(cfg->signer.service_name), "%s", svc[0] ? svc : "qubes.NsignerRpc");
}
/* Common: role + timeout. */
char role_buf[OW_MAX_SIGNER_ROLE_LEN] = {0};
if (read_line_prompt(" Role [main]: ", role_buf, sizeof(role_buf)) != 0) return -1;
if (line_is_quit(role_buf)) return -1;
snprintf(cfg->signer.role, sizeof(cfg->signer.role), "%s", role_buf[0] ? role_buf : "main");
if (chosen_mode[0] == '\0') {
/* No transport matched (should not happen after the '\0' guard
* above, but defensive). */
fprintf(stderr, "%sNo transport selected.%s\n", ANSI_RED, ANSI_RESET);
continue;
}
/* Common: key index + timeout. The key index selects which managed
* key n_signer uses (default 0). Role is left empty since the
* nostr_signer qube addresses keys by index. */
cfg->signer.role[0] = '\0';
char timeout_buf[32] = {0};
if (read_line_prompt(" Timeout ms [15000]: ", timeout_buf, sizeof(timeout_buf)) != 0) return -1;
@@ -2437,10 +2464,24 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign
cfg->signer.timeout_ms = (timeout_buf[0] != '\0') ? atoi(timeout_buf) : 15000;
if (cfg->signer.timeout_ms <= 0) cfg->signer.timeout_ms = 15000;
char index_buf[32] = {0};
if (read_line_prompt(" Key index [0]: ", index_buf, sizeof(index_buf)) != 0) return -1;
if (line_is_quit(index_buf)) return -1;
cfg->signer.nostr_index = (index_buf[0] != '\0') ? atoi(index_buf) : 0;
if (cfg->signer.nostr_index < 0) {
fprintf(stderr, "%sIndex must be >= 0; using 0.%s\n", ANSI_RED, ANSI_RESET);
cfg->signer.nostr_index = 0;
}
snprintf(cfg->signer.mode, sizeof(cfg->signer.mode), "%s", chosen_mode);
/* Connectivity check. */
fprintf(stderr, " Checking connectivity to n_signer (mode=%s)...\n", chosen_mode);
fprintf(stderr, " Checking connectivity to n_signer (mode=%s, target=%s, service=%s, index=%d)...\n",
chosen_mode,
cfg->signer.target_qube[0] ? cfg->signer.target_qube : "<n/a>",
cfg->signer.service_name[0] ? cfg->signer.service_name : "<n/a>",
cfg->signer.nostr_index);
fflush(stderr);
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
nostr_signer_t* signer = NULL;
if (strcmp(chosen_mode, "nsigner_unix") == 0) {
@@ -2467,6 +2508,11 @@ static int prompt_signer_transport(didactyl_config_t* cfg, nostr_signer_t** sign
cfg->signer.role, cfg->signer.timeout_ms);
}
/* Apply nostr_index selector when set (overrides role on the n_signer side). */
if (signer && cfg->signer.nostr_index >= 0) {
(void)nostr_signer_nsigner_set_nostr_index(signer, cfg->signer.nostr_index);
}
if (!signer) {
fprintf(stderr, "%sFailed to initialize n_signer client (mode=%s).%s\n",
ANSI_RED, chosen_mode, ANSI_RESET);
@@ -2514,34 +2560,39 @@ static nostr_signer_t* wizard_construct_ephemeral_signer(const didactyl_config_t
if (!cfg) return NULL;
#if defined(NOSTR_ENABLE_NSIGNER_CLIENT)
const char* mode = cfg->signer.mode;
nostr_signer_t* s = NULL;
if (strcmp(mode, "nsigner_unix") == 0) {
return nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL,
cfg->signer.role, cfg->signer.timeout_ms);
s = nostr_signer_nsigner_unix(cfg->signer.socket_name[0] ? cfg->signer.socket_name : NULL,
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) return NULL;
nostr_signer_t* s = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port,
cfg->signer.role, cfg->signer.timeout_ms);
s = nostr_signer_nsigner_tcp(cfg->signer.tcp_host, cfg->signer.tcp_port,
cfg->signer.role, cfg->signer.timeout_ms);
if (s && 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) {
(void)nostr_signer_nsigner_set_auth(s, auth_key, "didactyl");
}
}
return s;
} else if (strcmp(mode, "nsigner_serial") == 0) {
if (cfg->signer.serial_device[0] == '\0') return NULL;
return nostr_signer_nsigner_serial(cfg->signer.serial_device,
cfg->signer.role, cfg->signer.timeout_ms);
s = nostr_signer_nsigner_serial(cfg->signer.serial_device,
cfg->signer.role, cfg->signer.timeout_ms);
} else if (strcmp(mode, "nsigner_fds") == 0) {
if (cfg->signer.fds_read_fd < 0 || cfg->signer.fds_write_fd < 0) return NULL;
return nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd,
cfg->signer.role, cfg->signer.timeout_ms);
s = nostr_signer_nsigner_fds(cfg->signer.fds_read_fd, cfg->signer.fds_write_fd,
cfg->signer.role, cfg->signer.timeout_ms);
} else if (strcmp(mode, "nsigner_qrexec") == 0) {
if (cfg->signer.target_qube[0] == '\0') return NULL;
const char* svc = cfg->signer.service_name[0] ? cfg->signer.service_name : "qubes.NsignerRpc";
return nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc,
cfg->signer.role, cfg->signer.timeout_ms);
s = nostr_signer_nsigner_qrexec(cfg->signer.target_qube, svc,
cfg->signer.role, cfg->signer.timeout_ms);
}
/* Apply nostr_index selector when set (overrides role on the n_signer side). */
if (s && cfg->signer.nostr_index >= 0) {
(void)nostr_signer_nsigner_set_nostr_index(s, cfg->signer.nostr_index);
}
return s;
#else
(void)cfg;
#endif