From 82362e4f3f13f81a6fdc16dcc87459d158173e76 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Mar 2026 11:21:57 -0400 Subject: [PATCH] v0.0.80 - Fix wizard/runtime config persistence and add --admin CLI override for agent_config updates --- README.md | 4 +- plans/fix_kind30078_agent_config_publish.md | 69 +++++++ src/main.c | 56 +++-- src/main.h | 4 +- src/setup_wizard.c | 218 +++++++++++++++++++- 5 files changed, 327 insertions(+), 24 deletions(-) create mode 100644 plans/fix_kind30078_agent_config_publish.md diff --git a/README.md b/README.md index b5d6767..365e133 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,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.0.79 +## Current Status — v0.0.80 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.79 — Enhance existing-agent wizard with full config recovery review edit flow and new-agent fallback +> Last release update: v0.0.80 — Fix wizard/runtime config persistence and add --admin CLI override for agent_config updates - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/plans/fix_kind30078_agent_config_publish.md b/plans/fix_kind30078_agent_config_publish.md new file mode 100644 index 0000000..b9b350a --- /dev/null +++ b/plans/fix_kind30078_agent_config_publish.md @@ -0,0 +1,69 @@ +# Fix: Kind 30078 `d=agent_config` Publish Gaps + +## Problem + +The agent stores its admin pubkey and DM protocol in a NIP-44 self-encrypted kind 30078 replaceable event (`d=agent_config`). This event is critical for the `--nsec`-only startup path (used by systemd services) to recover the admin identity. + +**Multiple startup paths fail to publish this event**, causing: +- Agents installed via wizard "Install Service" to lose their admin pubkey on restart +- Agents recovering on new servers to use stale/old admin pubkeys from relays +- Agents with genesis files to silently ignore newer kind 30078 values + +## Affected Files + +- `src/setup_wizard.c` — wizard flows +- `src/main.c` — main startup logic + +## Fixes + +### Fix A: `new_agent_flow` — publish before service install +**File:** `src/setup_wizard.c` ~line 2184 +**Problem:** When user chooses "Install systemd service" in new agent flow, `persist_runtime_config_to_nostr_wizard_online` is never called. The service starts with `--nsec` only and has no kind 30078 to recover from. +**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before `install_system_service_with_dedicated_user()`. Fail the install if publish fails (same pattern as existing_agent_flow). + +### Fix B: `new_agent_flow` — publish before "boot now" return +**File:** `src/setup_wizard.c` ~line 2177 +**Problem:** When user chooses "Boot now", the wizard returns `SETUP_WIZARD_RC_BOOTSTRAP` and relies on `main()` to publish later. This works but is fragile — if the bootstrap publish in `main()` fails, the kind 30078 is never created. +**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before returning 0. This is belt-and-suspenders — `main()` will also publish, but the wizard ensures it happens at least once. If the wizard publish fails, log a warning but continue (non-fatal since main will retry). + +### Fix C: `existing_agent_flow` — always publish before service install +**File:** `src/setup_wizard.c` ~line 2368 +**Problem:** `persist_runtime_config_to_nostr_wizard_online` is only called `if (config_changed)`. If the user accepts the recovered config as-is, the kind 30078 is not re-published. The event may have been lost from relays. +**Fix:** Remove the `config_changed` gate. Always call `persist_runtime_config_to_nostr_wizard_online(cfg)` before installing the service. Kind 30078 is a replaceable event, so re-publishing is safe and idempotent. + +### Fix D: `existing_agent_flow` — always return BOOTSTRAP +**File:** `src/setup_wizard.c` ~line 2364 +**Problem:** When user chooses "Boot now" and `config_changed == 0`, the flow returns `SETUP_WIZARD_RC_EXISTING` which means `bootstrap_mode = 0` in `main()`. If `first_run = 0` (kind 10002 exists), `persist_runtime_config_to_nostr` is skipped. +**Fix:** Always return `SETUP_WIZARD_RC_BOOTSTRAP` from the "boot now" path, regardless of `config_changed`. This ensures `main()` always re-publishes the kind 30078. The wizard has all the config in memory — it should always be treated as authoritative. + +### Fix E: `persist_runtime_config_to_nostr_wizard_online` — ensure relay delivery +**File:** `src/setup_wizard.c` ~line 1159 +**Problem:** The function publishes the event and immediately calls `nostr_handler_cleanup()`. The event may not have been delivered to relays yet (fire-and-forget). +**Fix:** Add a brief poll loop (e.g., 2-3 seconds of `nostr_handler_poll()`) after the publish call and before `nostr_handler_cleanup()` to give the event time to propagate. This matches the pattern used elsewhere for relay operations. + +### Fix F: `main()` — always fetch kind 30078 and compare +**File:** `src/main.c` ~line 847 (`recover_missing_runtime_config_from_nostr`) +**Problem:** The recovery only runs when `admin_config_is_complete()` returns false. If a genesis file provides an admin pubkey, the kind 30078 is never checked, even if it has a different (newer) value. +**Fix:** Always fetch the kind 30078 `d=agent_config` regardless of whether the genesis already provided values. Compare the fetched admin_pubkey with the loaded one. If they differ, log a `DEBUG_WARN` with both values. Genesis wins (operator intent), but the warning makes the conflict visible in logs. + +### Fix G: `main()` — always re-publish kind 30078 +**File:** `src/main.c` ~line 1152 +**Problem:** `persist_runtime_config_to_nostr` only runs when `bootstrap_mode || first_run`. On subsequent runs, the kind 30078 is never refreshed. If relays purge the event, it's gone. +**Fix:** Move `persist_runtime_config_to_nostr(&cfg)` outside the `if (bootstrap_mode || first_run)` block so it runs on every startup. Kind 30078 is a replaceable event — re-publishing is safe, idempotent, and ensures relay persistence. Log the result but don't fail startup if it doesn't succeed. + +## Execution Order + +1. Fix E first (relay delivery) — this makes all other wizard publishes more reliable +2. Fixes A + B (new_agent_flow) — the most critical bug +3. Fixes C + D (existing_agent_flow) — second most critical +4. Fix G (always re-publish in main) — ensures long-term relay persistence +5. Fix F (conflict detection) — nice-to-have logging improvement + +## Testing + +- Wizard → New Agent → Install Service: verify kind 30078 exists on relays after service starts +- Wizard → New Agent → Boot: verify kind 30078 exists on relays +- Wizard → Existing Agent → Install Service (no changes): verify kind 30078 re-published +- Wizard → Existing Agent → Boot (no changes): verify kind 30078 re-published +- `--nsec` only restart: verify kind 30078 recovered and re-published +- `--config genesis.jsonc` with different admin than kind 30078: verify warning logged, genesis wins, kind 30078 updated diff --git a/src/main.c b/src/main.c index e056eff..bf34343 100644 --- a/src/main.c +++ b/src/main.c @@ -75,6 +75,8 @@ static void print_usage(const char* prog) { " If file is missing, startup falls back to minimal nsec-only mode.\n" " --nsec \n" " Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.\n" + " --admin \n" + " Administrator pubkey override; accepts npub1... or 64-char hex.\n" " --api-port \n" " Enable HTTP API and bind to this port (1-65535).\n" " --api-bind \n" @@ -863,24 +865,32 @@ static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) { free(llm_plaintext); } - if (!admin_config_is_complete(cfg)) { - char* agent_plaintext = NULL; - if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) { - if (apply_recalled_agent_config(cfg, agent_plaintext) == 0) { + char* agent_plaintext = NULL; + if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) { + didactyl_config_t recalled = *cfg; + if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) { + if (!admin_config_is_complete(cfg)) { + snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey); + cfg->dm_protocol = recalled.dm_protocol; DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr"); - } else { - DEBUG_WARN("[didactyl] startup phase: failed to apply recalled agent_config"); + } else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) { + DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins", + cfg->admin.pubkey, + recalled.admin.pubkey); } } else { - DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable"); + DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config"); } - free(agent_plaintext); + } else { + DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable"); } + free(agent_plaintext); } int main(int argc, char** argv) { const char* config_path = "./genesis.jsonc"; const char* cli_nsec = NULL; + const char* cli_admin_pubkey = NULL; const char* api_bind_override = NULL; int api_port_override = 0; int debug_level = DEBUG_LEVEL_NONE; @@ -944,6 +954,8 @@ int main(int argc, char** argv) { debug_level = atoi(argv[++i]); } else if (strcmp(argv[i], "--nsec") == 0 && i + 1 < argc) { cli_nsec = argv[++i]; + } else if (strcmp(argv[i], "--admin") == 0 && i + 1 < argc) { + cli_admin_pubkey = argv[++i]; } else if (strcmp(argv[i], "--api-port") == 0 && i + 1 < argc) { api_port_override = atoi(argv[++i]); } else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) { @@ -997,6 +1009,18 @@ int main(int argc, char** argv) { } } + if (cli_admin_pubkey && cli_admin_pubkey[0] != '\0') { + char decoded_admin[65] = {0}; + if (decode_pubkey_hex_or_npub_local(cli_admin_pubkey, decoded_admin) != 0) { + fprintf(stderr, "Invalid admin pubkey provided via --admin (expected npub1... or 64-char hex)\n"); + config_free(&cfg); + nostr_cleanup(); + return 1; + } + snprintf(cfg.admin.pubkey, sizeof(cfg.admin.pubkey), "%s", decoded_admin); + DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey); + } + if (config_ensure_default_skill_startup_events(&cfg) != 0) { fprintf(stderr, "Failed to synthesize startup events from default_skill\n"); config_free(&cfg); @@ -1124,7 +1148,7 @@ int main(int argc, char** argv) { if (!admin_config_is_complete(&cfg)) { startup_step_fail(4, "Validate admin config", "missing admin pubkey"); fprintf(stderr, - "Missing admin config: provide admin.pubkey in genesis or store d=agent_config via config_store\n"); + "Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n"); nostr_handler_cleanup(); config_free(&cfg); nostr_cleanup(); @@ -1153,17 +1177,19 @@ int main(int argc, char** argv) { if (nostr_handler_reconcile_startup_events() != 0) { DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)"); } - if (persist_runtime_config_to_nostr(&cfg) != 0) { - DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr"); - } else { - DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr"); - } } else { - DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping bootstrap publish on subsequent run"); + DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run"); if (nostr_handler_load_system_context_from_adopted_skills() != 0) { DEBUG_WARN("[didactyl] startup phase: failed to load system context from adopted skills (continuing with fallback)"); } } + + if (persist_runtime_config_to_nostr(&cfg) != 0) { + DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr"); + } else { + DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr"); + } + startup_step_ok(7, "Reconcile/persist startup state", NULL); const char* system_context = nostr_handler_get_system_context(); diff --git a/src/main.h b/src/main.h index 6df2185..217ec3a 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 0 -#define DIDACTYL_VERSION_PATCH 79 -#define DIDACTYL_VERSION "v0.0.79" +#define DIDACTYL_VERSION_PATCH 80 +#define DIDACTYL_VERSION "v0.0.80" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 343ee00..775e392 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -1056,6 +1056,167 @@ static int recover_full_config_from_nostr(didactyl_config_t* cfg, return 0; } +static const char* dm_protocol_to_string_local(dm_protocol_t protocol) { + switch (protocol) { + case DM_PROTOCOL_NIP17: return "nip17"; + case DM_PROTOCOL_BOTH: return "both"; + case DM_PROTOCOL_NIP04: + default: + return "nip04"; + } +} + +static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg, + 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; + + 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(); + cJSON* d = cJSON_CreateArray(); + cJSON* app = cJSON_CreateArray(); + if (!tags || !d || !app) { + cJSON_Delete(tags); + cJSON_Delete(d); + cJSON_Delete(app); + free(ciphertext); + return -1; + } + + cJSON_AddItemToArray(d, cJSON_CreateString("d")); + cJSON_AddItemToArray(d, cJSON_CreateString(d_tag)); + cJSON_AddItemToArray(tags, d); + + cJSON_AddItemToArray(app, cJSON_CreateString("app")); + cJSON_AddItemToArray(app, cJSON_CreateString("didactyl")); + cJSON_AddItemToArray(tags, app); + + nostr_publish_result_t result; + memset(&result, 0, sizeof(result)); + int rc = nostr_handler_publish_kind_event(30078, ciphertext, tags, &result); + nostr_handler_publish_result_free(&result); + cJSON_Delete(tags); + free(ciphertext); + return rc; +} + +static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) { + if (!cfg) return -1; + + cJSON* llm = cJSON_CreateObject(); + if (!llm) return -1; + cJSON_AddStringToObject(llm, "provider", cfg->llm.provider); + cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key); + cJSON_AddStringToObject(llm, "model", cfg->llm.model); + cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url); + cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens); + cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature); + + char* llm_json = cJSON_PrintUnformatted(llm); + cJSON_Delete(llm); + if (!llm_json) return -1; + + int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json); + free(llm_json); + + cJSON* agent = cJSON_CreateObject(); + if (!agent) return llm_rc; + cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey); + cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol)); + char* agent_json = cJSON_PrintUnformatted(agent); + cJSON_Delete(agent); + if (!agent_json) return llm_rc; + + int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json); + free(agent_json); + + return (llm_rc == 0 && agent_rc == 0) ? 0 : -1; +} + +static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) { + if (!cfg) return -1; + + didactyl_config_t tmp = *cfg; + if (nostr_handler_init(&tmp) != 0) { + return -1; + } + + (void)wait_connected_relays_ms(5000); + int rc = persist_runtime_config_to_nostr_wizard(cfg); + + /* Give relays time to acknowledge the published events before tearing + down the connection. Without this, the fire-and-forget publish may + not reach any relay before cleanup closes the sockets. */ + for (int i = 0; i < 30; i++) { + nostr_handler_poll(100); + } + + nostr_handler_cleanup(); + return rc; +} + +static int publish_kind10002_relays_wizard_online(const didactyl_config_t* cfg) { + if (!cfg) return -1; + + didactyl_config_t tmp = *cfg; + if (nostr_handler_init(&tmp) != 0) { + return -1; + } + + (void)wait_connected_relays_ms(5000); + + char* tags_json = NULL; + if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) { + free(tags_json); + nostr_handler_cleanup(); + return -1; + } + + cJSON* tags = cJSON_Parse(tags_json); + free(tags_json); + if (!tags || !cJSON_IsArray(tags)) { + cJSON_Delete(tags); + nostr_handler_cleanup(); + return -1; + } + + nostr_publish_result_t result; + memset(&result, 0, sizeof(result)); + int rc = nostr_handler_publish_kind_event(10002, "", tags, &result); + nostr_handler_publish_result_free(&result); + cJSON_Delete(tags); + + nostr_handler_cleanup(); + return rc; +} + +static int ensure_startup_kind10002_from_current_relays(didactyl_config_t* cfg) { + if (!cfg) return -1; + + char* tags_json = NULL; + if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) { + free(tags_json); + return -1; + } + + int rc = upsert_startup_event(cfg, 10002, "", tags_json); + free(tags_json); + return rc; +} + static int wizard_llm_test(const llm_config_t* llm_cfg) { if (!llm_cfg) return -1; if (llm_init(llm_cfg) != 0) { @@ -2022,6 +2183,13 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t if (c == 's') return 2; if (c == 'b') { + /* Publish kind 30078 agent_config + llm_config so the --nsec-only + restart path can recover them. Non-fatal: main() will also + publish during bootstrap, but doing it here is belt-and-suspenders. */ + if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { + fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n", + ANSI_YELLOW, ANSI_RESET); + } fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n", ANSI_YELLOW, ANSI_RESET); @@ -2029,6 +2197,16 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t } if (c == 'i') { + /* Publish kind 30078 agent_config + llm_config BEFORE installing the + service. The systemd service starts with --nsec only and depends + on recovering these from relays. */ + if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { + fprintf(stderr, + "%sFailed to publish runtime config to Nostr; refusing install to avoid missing admin/LLM on first boot.%s\n", + ANSI_RED, ANSI_RESET); + return -1; + } + if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); return -1; @@ -2069,7 +2247,7 @@ static int existing_agent_flow(didactyl_config_t* cfg) { char nsec_in[OW_MAX_KEY_LEN] = {0}; char agent_name[OW_MAX_NAME_LEN] = {0}; int agent_name_found = 0; - int config_changed = 0; + int relay_changed = 0; render_wizard_page_header("Existing Agent", "Identity"); if (read_secret_prompt("Enter your agent nsec (nsec1... or 64-char hex): ", nsec_in, sizeof(nsec_in)) != 0) return -1; @@ -2124,6 +2302,7 @@ static int existing_agent_flow(didactyl_config_t* cfg) { } render_wizard_page_header("Existing Agent", "Recovered Configuration"); + fprintf(stderr, "Your agent was found. Change any of the following:\n\n"); fprintf(stderr, " Name: %s\n", agent_name); fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex); fprintf(stderr, " Admin: %s\n", cfg->admin.pubkey[0] ? cfg->admin.pubkey : "(not set)"); @@ -2154,7 +2333,6 @@ static int existing_agent_flow(didactyl_config_t* cfg) { "Existing Agent -- Administrator") != 0) { return -1; } - config_changed = 1; continue; } if (c == 'l') { @@ -2163,7 +2341,6 @@ static int existing_agent_flow(didactyl_config_t* cfg) { "Existing Agent -- LLM Provider") != 0) { return -1; } - config_changed = 1; continue; } if (c == 'r') { @@ -2173,7 +2350,7 @@ static int existing_agent_flow(didactyl_config_t* cfg) { "Existing Agent -- Relay Configuration"); if (rc < 0) return -1; if (rc == 1) break; - config_changed = 1; + relay_changed = 1; break; } continue; @@ -2196,10 +2373,41 @@ static int existing_agent_flow(didactyl_config_t* cfg) { if (lc == 'q') return -1; if (lc == 'b') { - return config_changed ? SETUP_WIZARD_RC_BOOTSTRAP : SETUP_WIZARD_RC_EXISTING; + if (relay_changed) { + if (ensure_startup_kind10002_from_current_relays(cfg) != 0) { + fprintf(stderr, + "%sFailed to stage updated relay list for startup publish.%s\n", + ANSI_RED, + ANSI_RESET); + return -1; + } + } + /* Always return BOOTSTRAP so main() re-publishes kind 30078. + The wizard has the authoritative config in memory. */ + return SETUP_WIZARD_RC_BOOTSTRAP; } if (lc == 'i') { + /* Always publish kind 30078 before installing the service, even if + config was not changed. The service depends on recovering these + from relays and the event may have been purged. */ + if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) { + fprintf(stderr, + "%sFailed to publish runtime config to Nostr; refusing install to avoid stale admin/LLM on first boot.%s\n", + ANSI_RED, + ANSI_RESET); + return -1; + } + if (relay_changed) { + if (publish_kind10002_relays_wizard_online(cfg) != 0) { + fprintf(stderr, + "%sFailed to publish updated relay list (kind 10002) to Nostr; refusing install.%s\n", + ANSI_RED, + ANSI_RESET); + return -1; + } + } + if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) { fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET); return -1;