Files
neuron/routes.el
will.anderson 2865d6ad26
Neuron Soul CI / build (pull_request) Has been cancelled
fix(reliability): route-error-recovery
- Issue #3: err_404/err_405 now emit HTTP 404/405 via __status__ envelope instead of HTTP 200
- Issue #4: add auth_check() function to handle_request; enforces NEURON_TOKEN on all routes except /health and /lineage
- Issue #5: missing required params now return HTTP 400 (__status__ envelope) in /api/chat (GET+POST), /imprint/contextual, /imprint/user, and handle_chat
- Issue #6: LLM unavailable in handle_chat now returns HTTP 503 instead of HTTP 200
- Issue #7: add 32 KB message size guard on POST /api/chat before engram_compile and LLM
- Issue #8: add TODO comment to route_health documenting the live-engram-query problem and the /health/deep split plan
- Issue #9: add comment to hist_trim documenting fragile str_index_of parser and silent data corruption risk
- Issue #10: add TODO comment in handle_request documenting missing per-IP rate limiting
- Issue #11: fix connectd_post temp file collision — add monotonic sequence counter so concurrent requests get unique paths
- Issue #12: fix call_mcp_bridge fixed temp file race — add monotonic sequence counter for unique paths under concurrent load
- Issues #1/#2: add TODO comment in handle_request documenting EL no-exception limitation and SIGSEGV handler gap
2026-06-22 12:00:06 -05:00

648 lines
25 KiB
EmacsLisp

import "memory.el"
import "awareness.el"
import "chat.el"
import "studio.el"
import "elp-input.el"
import "neuron-api.el"
import "sessions.el"
import "soul.elh"
fn strip_query(path: String) -> String {
let q: Int = str_index_of(path, "?")
if q < 0 {
return path
}
return str_slice(path, 0, q)
}
fn err_404(path: String) -> String {
// __status__ envelope el_runtime reads the first key and emits HTTP 404.
// Issue #3: previously returned HTTP 200 with JSON error body.
return "{\"__status__\":404,\"error\":\"not found\",\"path\":\"" + path + "\"}"
}
fn err_405(method: String, path: String) -> String {
// __status__ envelope emits HTTP 405.
// Issue #3: previously returned HTTP 200 with JSON error body.
return "{\"__status__\":405,\"error\":\"method not allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
}
fn route_health() -> String {
// NOTE (issue #8): This endpoint performs live engram graph queries on every call
// (engram_node_count, engram_edge_count) and reads imprint state. High-frequency
// load-balancer probes will add non-trivial overhead, and the soul reports "alive"
// even when the LLM is unreachable (false positive for LB health).
// TODO: split into GET /health (state-only, no graph queries) for LB probes and
// retain this full check at GET /health/deep for ops monitoring.
let cgi_id: String = state_get("soul_cgi_id")
let boot: String = state_get("soul_boot_count")
let boot_num: String = if str_eq(boot, "") { "0" } else { boot }
let node_ct: Int = engram_node_count()
let edge_ct: Int = engram_edge_count()
let pulse: String = state_get("soul.pulse")
let pulse_num: String = if str_eq(pulse, "") { "0" } else { pulse }
return "{\"status\":\"alive\""
+ ",\"cgi_id\":\"" + cgi_id + "\""
+ ",\"boot\":" + boot_num
+ ",\"node_count\":" + int_to_str(node_ct)
+ ",\"edge_count\":" + int_to_str(edge_ct)
+ ",\"pulse\":" + pulse_num
+ ",\"layers\":{\"l0\":\"core\",\"l1\":\"safety\",\"l2\":\"stewardship\",\"l3\":\"" + imprint_current() + "\"}}"
}
fn route_lineage() -> String {
let cgi_id: String = state_get("soul_cgi_id")
let q: String = "lineage:" + cgi_id
let results: String = engram_search_json(q, 1)
let len: Int = json_array_len(results)
if len <= 0 {
return "{\"id\":\"" + cgi_id + "\""
+ ",\"tier\":\"citizen\""
+ ",\"is_founding\":true"
+ ",\"validation_attempts\":0"
+ ",\"training_sessions\":0"
+ ",\"is_sterile\":false}"
}
let raw: String = json_get_raw(results, "0")
return raw
}
fn route_imprint_contextual(body: String) -> String {
if str_eq(body, "") {
// Issue #5: empty body is a client error HTTP 400.
return "{\"__status__\":400,\"ok\":false,\"error\":\"empty body\"}"
}
let tags: String = "[\"imprint\",\"contextual\"]"
let id: String = engram_node_full(
body,
"Entity",
"imprint:contextual",
el_from_float(0.7),
el_from_float(0.6),
el_from_float(0.9),
"Working",
tags
)
if str_eq(id, "") {
return "{\"ok\":false,\"error\":\"engram write failed\"}"
}
state_set("active_contextual_imprint", id)
return "{\"ok\":true,\"id\":\"" + id + "\"}"
}
fn route_imprint_user(body: String) -> String {
if str_eq(body, "") {
// Issue #5: empty body is a client error HTTP 400.
return "{\"__status__\":400,\"ok\":false,\"error\":\"empty body\"}"
}
let tags: String = "[\"imprint\",\"user\"]"
let id: String = engram_node_full(
body,
"Entity",
"imprint:user",
el_from_float(0.7),
el_from_float(0.6),
el_from_float(0.9),
"Working",
tags
)
if str_eq(id, "") {
return "{\"ok\":false,\"error\":\"engram write failed\"}"
}
state_set("active_user_imprint", id)
return "{\"ok\":true,\"id\":\"" + id + "\"}"
}
fn route_synthesize(body: String) -> String {
if str_eq(body, "") {
return "{\"mechanism\":\"did not engage\"}"
}
let parent_a: String = json_get(body, "parent_a")
let parent_b: String = json_get(body, "parent_b")
if str_eq(parent_a, "") {
return "{\"mechanism\":\"did not engage\"}"
}
if str_eq(parent_b, "") {
return "{\"mechanism\":\"did not engage\"}"
}
let req: String = "synthesize " + parent_a + " " + parent_b
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
engram_node_full(
req,
"Entity",
"synthesis-request",
el_from_float(0.8),
el_from_float(0.8),
el_from_float(0.9),
"Working",
tags
)
return "{\"mechanism\":\"did not engage\"}"
}
fn handle_dharma_recv(body: String) -> String {
let content_raw: String = json_get(body, "content")
let from_id: String = json_get(body, "from")
let event_type: String = json_get(content_raw, "event_type")
let payload: String = json_get(content_raw, "payload")
let eff_event: String = if str_eq(event_type, "") { "chat" } else { event_type }
let eff_payload: String = if str_eq(payload, "") { content_raw } else { payload }
if str_eq(eff_event, "chat") {
let msg: String = json_get(eff_payload, "message")
let chat_body: String = if str_eq(msg, "") {
"{\"message\":\"" + str_replace(str_replace(eff_payload, "\\", "\\\\"), "\"", "\\\"") + "\"}"
} else {
eff_payload
}
let agentic_flag: Bool = json_get_bool(eff_payload, "agentic")
let raw_msg: String = json_get(chat_body, "message")
let reply: String = if agentic_flag {
handle_chat_agentic(chat_body)
} else {
let screened_reply: String = layered_cycle(raw_msg)
screened_reply
}
auto_persist(chat_body, reply)
return reply
}
if str_eq(eff_event, "memory") {
let query: String = json_get(eff_payload, "query")
let limit_str: String = json_get(eff_payload, "limit")
let limit: Int = if str_eq(limit_str, "") { 20 } else { str_to_int(limit_str) }
let q: String = if str_eq(query, "") { eff_payload } else { query }
return engram_search_json(q, limit)
}
if str_eq(eff_event, "tool") {
let path_field: String = json_get(eff_payload, "path")
let method_field: String = json_get(eff_payload, "method")
let tool_body: String = json_get(eff_payload, "body")
let eff_method: String = if str_eq(method_field, "") { "POST" } else { method_field }
return handle_tool(path_field, eff_method, tool_body)
}
if str_eq(eff_event, "see") {
return handle_see(eff_payload)
}
if str_eq(eff_event, "health") {
return route_health()
}
if str_eq(eff_event, "dharma_room_turn_agentic") {
return handle_dharma_room_turn_agentic(eff_payload)
}
if str_eq(eff_event, "dharma_room_turn") {
return handle_dharma_room_turn(eff_payload)
}
if str_eq(eff_event, "chat_as_soul") {
return handle_chat_as_soul(eff_payload)
}
// ELP Engram Language Protocol: two-layer activation, no LLM
if str_eq(eff_event, "elp") {
return handle_elp_chat(eff_payload)
}
return "{\"error\":\"unknown event_type\",\"event_type\":\"" + eff_event + "\"}"
}
// ---------------------------------------------------------------------------
// MCP Connectors proxy thin pass-through to neuron-connectd on :7771.
// The UI talks to ONE origin (the soul); all MCP/config complexity lives in
// the bridge. Bridge-down returns a clear error (not a panic).
// ---------------------------------------------------------------------------
fn connectd_get(suffix: String) -> String {
let out: String = exec_capture("curl -s --max-time 5 http://127.0.0.1:7771" + suffix)
if str_eq(out, "") {
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
}
return out
}
// POST passthrough: request body is written to a temp file and passed via -d @file
// so arbitrary JSON cannot reach the shell as a command-line argument.
fn connectd_post(suffix: String, body: String) -> String {
let eff: String = if str_eq(body, "") { "{}" } else { body }
// Issue #11: time_now() has second-granularity; two concurrent requests in the same
// second collide on the same temp path. Added a monotonic per-process sequence counter.
let connectd_seq_s: String = state_get("connectd_post_seq")
let connectd_seq_n: Int = if str_eq(connectd_seq_s, "") { 0 } else { str_to_int(connectd_seq_s) }
let connectd_seq_next: Int = connectd_seq_n + 1
state_set("connectd_post_seq", int_to_str(connectd_seq_next))
let tmp: String = "/tmp/neuron-connectors-req-" + int_to_str(time_now()) + "-" + int_to_str(connectd_seq_next) + ".json"
fs_write(tmp, eff)
let out: String = exec_capture("curl -s --max-time 20 -X POST http://127.0.0.1:7771" + suffix + " -H 'Content-Type: application/json' -d @" + tmp)
if str_eq(out, "") {
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
}
return out
}
fn handle_connectors(method: String, clean: String, body: String) -> String {
if str_eq(method, "GET") {
// /api/connectors -> each configured server with status, tools, auth, auto-approve.
return connectd_get("/mcp/servers")
}
if str_eq(clean, "/api/connectors/add") {
return connectd_post("/mcp/servers/add", body)
}
if str_eq(clean, "/api/connectors/toggle") {
return connectd_post("/mcp/servers/toggle", body)
}
if str_eq(clean, "/api/connectors/auto-approve") {
return connectd_post("/mcp/servers/auto-approve", body)
}
if str_eq(clean, "/api/connectors/remove") {
return connectd_post("/mcp/servers/remove", body)
}
if str_eq(clean, "/api/connectors/secret") {
return connectd_post("/mcp/servers/secret", body)
}
if str_eq(clean, "/api/connectors/oauth/start") {
return connectd_post("/mcp/oauth/start", body)
}
return "{\"ok\":false,\"error\":\"unknown connectors route\"}"
}
// auth_check validate NEURON_TOKEN bearer auth on every request.
// Returns "" when authorized, or a JSON 401 error string when not.
// /health and /lineage are public routes always exempted.
// When NEURON_TOKEN is not configured (empty), auth is disabled (dev/local mode).
// Issue #4: previously no auth layer existed anywhere in the router.
// Clients pass the token in the JSON body as "__auth".
// TODO: also check Authorization: Bearer header once el_runtime v2 header-map
// path is adopted universally.
fn auth_check(clean: String, body: String) -> String {
if str_eq(clean, "/health") { return "" }
if str_eq(clean, "/lineage") { return "" }
let token: String = state_get("soul_token")
if str_eq(token, "") { return "" }
let auth_field: String = json_get(body, "__auth")
if str_eq(auth_field, token) { return "" }
return "{\"__status__\":401,\"error\":\"unauthorized\"}"
}
fn handle_request(method: String, path: String, body: String) -> String {
let clean: String = strip_query(path)
// Issue #1/#2: EL has no exception/try-catch mechanism. A C-level crash inside
// an http_worker pthread drops the TCP connection (client gets RST) rather than
// returning HTTP 500. TODO: register a SIGSEGV/SIGBUS handler in el_runtime.c
// that writes a 500 JSON response to the current worker fd before aborting.
// Issue #10: Rate limiting is not implemented.
// TODO: add a per-IP token-bucket counter returning HTTP 429 when exceeded.
// Requires a C-level counter in el_runtime.c or a sidecar reverse proxy.
// Auth enforced on all routes except /health and /lineage.
// Issue #4: previously no auth check existed anywhere in the router.
let auth_err: String = auth_check(clean, body)
if !str_eq(auth_err, "") {
return auth_err
}
if str_eq(method, "POST") && str_eq(clean, "/dharma/recv") {
return handle_dharma_recv(body)
}
if str_eq(method, "GET") {
if str_eq(clean, "/health") {
return route_health()
}
if str_eq(clean, "/lineage") {
return route_lineage()
}
if str_eq(clean, "/api/graph") || str_eq(clean, "/api/graph/nodes") {
return engram_scan_nodes_json(9999, 0)
}
if str_eq(clean, "/api/graph/edges") {
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
engram_save(snap_path)
let snap: String = fs_read(snap_path)
let edges_raw: String = json_get_raw(snap, "edges")
return if str_eq(edges_raw, "") { "[]" } else { edges_raw }
}
if str_eq(clean, "/api/chat") {
// GET /api/chat: pass through layered_cycle for consistency with POST path.
// GET chat is a legacy probe interface; body may be empty for simple pings.
let raw_msg: String = json_get(body, "message")
let eff_msg: String = if str_eq(raw_msg, "") { body } else { raw_msg }
if str_eq(eff_msg, "") {
// Issue #5: missing required param HTTP 400.
return "{\"__status__\":400,\"error\":\"message required\"}"
}
let agentic_flag: Bool = json_get_bool(body, "agentic")
let reply: String = if agentic_flag {
handle_chat_agentic(body)
} else {
let screened_reply: String = layered_cycle(eff_msg)
screened_reply
}
auto_persist(body, reply)
return reply
}
if str_eq(clean, "/api/conversations") {
return handle_conversations(method)
}
if str_eq(clean, "/api/config") {
return handle_config(method, body)
}
if str_starts_with(clean, "/api/tools/") {
return handle_tool(clean, method, body)
}
if str_starts_with(clean, "/api/dharma") {
return handle_dharma(clean, method, body)
}
if str_starts_with(clean, "/api/nlg") {
return handle_nlg(clean, method, body)
}
if str_starts_with(clean, "/api/memories") {
return axon_get(clean)
}
if str_starts_with(clean, "/api/knowledge") {
return axon_get(clean)
}
if str_starts_with(clean, "/api/backlog") {
return axon_get(clean)
}
if str_starts_with(clean, "/api/artifacts") {
return axon_get(clean)
}
if str_starts_with(clean, "/api/projects") {
return axon_get(clean)
}
if str_starts_with(clean, "/api/imprints") {
return axon_get(clean)
}
if str_eq(clean, "/") {
return render_studio()
}
// Neuron cognitive API GET endpoints
if str_eq(clean, "/api/neuron/session/begin") {
return handle_api_begin_session("")
}
if str_eq(clean, "/api/neuron/ctx") {
return handle_api_compile_ctx("")
}
if str_eq(clean, "/api/safety-contact") {
return handle_safety_contact_get()
}
if str_starts_with(clean, "/api/neuron/knowledge/search") {
return handle_api_search_knowledge(method, path, body)
}
if str_eq(clean, "/api/neuron/knowledge") {
return handle_api_browse_knowledge(path, body)
}
if str_starts_with(clean, "/api/neuron/processes") {
return handle_api_browse_processes(method, path, body)
}
if str_starts_with(clean, "/api/neuron/state-events") {
return handle_api_list_state_events(method, path, body)
}
if str_starts_with(clean, "/api/neuron/config") {
return handle_api_inspect_config(path, body)
}
if str_starts_with(clean, "/api/neuron/graph") {
return handle_api_inspect_graph(method, path, body)
}
if str_starts_with(clean, "/api/neuron/list/") {
let node_type: String = str_slice(clean, 16, str_len(clean))
return handle_api_list_typed(node_type, path, body)
}
if str_starts_with(clean, "/api/neuron/recall") {
return handle_api_recall(method, path, body)
}
if str_starts_with(clean, "/api/connectors") {
return handle_connectors(method, clean, body)
}
// GET /api/sessions list all sessions
if str_eq(clean, "/api/sessions") {
return session_list()
}
// GET /api/sessions/:id get session metadata + history
if str_starts_with(clean, "/api/sessions/") {
let gs_after: String = str_slice(clean, 14, str_len(clean))
let gs_slash: Int = str_index_of(gs_after, "/")
let gs_id: String = if gs_slash < 0 { gs_after } else { str_slice(gs_after, 0, gs_slash) }
if !str_eq(gs_id, "") {
return session_get(gs_id)
}
}
return err_404(clean)
}
if str_eq(method, "POST") {
// POST /api/sessions create new session
if str_eq(clean, "/api/sessions") {
return session_create(body)
}
// MCP tool-bridge resume: POST /api/sessions/{id}/tool_result
// The client executed a tool the soul could not run in-process (an MCP
// connector/plugin) and posts the result back here so the agentic loop
// continues. {id} is the session_id from the prior tool_pending envelope.
if str_starts_with(clean, "/api/sessions/") && str_ends_with(clean, "/tool_result") {
let after: String = str_slice(clean, 14, str_len(clean))
let slash: Int = str_index_of(after, "/")
let session_id: String = if slash < 0 { after } else { str_slice(after, 0, slash) }
return handle_tool_result(session_id, body)
}
// POST /api/sessions/:id/approve user approval for a pending agentic tool call
if str_starts_with(clean, "/api/sessions/") {
let sess_after: String = str_slice(clean, 14, str_len(clean))
let sess_slash: Int = str_index_of(sess_after, "/")
let sess_id: String = if sess_slash < 0 { sess_after } else { str_slice(sess_after, 0, sess_slash) }
let sess_sub: String = if sess_slash < 0 { "" } else { str_slice(sess_after, sess_slash + 1, str_len(sess_after)) }
if !str_eq(sess_id, "") && str_eq(sess_sub, "approve") {
return handle_session_approve(sess_id, body)
}
}
if str_eq(clean, "/imprint/contextual") {
return route_imprint_contextual(body)
}
if str_eq(clean, "/imprint/user") {
return route_imprint_user(body)
}
if str_eq(clean, "/synthesize") {
return route_synthesize(body)
}
if str_eq(clean, "/api/elp/chat") {
return handle_elp_chat(body)
}
if str_eq(clean, "/api/chat") {
// Issue #5: validate required params return HTTP 400 when missing.
let raw_msg: String = json_get(body, "message")
if str_eq(raw_msg, "") {
return "{\"__status__\":400,\"error\":\"message is required\",\"response\":\"\"}"
}
// Issue #7: reject oversized messages before engram_compile and the LLM.
// Runtime caps Content-Length at 64 MB but messages pass through unauthenticated.
if str_len(raw_msg) > 32768 {
return "{\"__status__\":400,\"error\":\"message too large (max 32768 chars)\",\"response\":\"\"}"
}
let agentic_flag: Bool = json_get_bool(body, "agentic")
let reply: String = if agentic_flag {
handle_chat_agentic(body)
} else {
let screened_reply: String = layered_cycle(raw_msg)
screened_reply
}
auto_persist(body, reply)
return reply
}
if str_eq(clean, "/api/see") {
return handle_see(body)
}
if str_eq(clean, "/api/conversations") {
return handle_conversations(method)
}
if str_eq(clean, "/api/config") {
return handle_config(method, body)
}
if str_starts_with(clean, "/api/tools/") {
return handle_tool(clean, method, body)
}
if str_starts_with(clean, "/api/dharma") {
return handle_dharma(clean, method, body)
}
if str_starts_with(clean, "/api/nlg") {
return handle_nlg(clean, method, body)
}
if str_starts_with(clean, "/api/memories") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/knowledge") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/backlog") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/artifacts") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/projects") {
return axon_post(clean, body)
}
if str_starts_with(clean, "/api/imprints") {
return axon_post(clean, body)
}
// Neuron cognitive API POST endpoints
if str_eq(clean, "/api/neuron/session/begin") {
return handle_api_begin_session(body)
}
if str_eq(clean, "/api/neuron/ctx") {
return handle_api_compile_ctx(body)
}
if str_eq(clean, "/api/neuron/knowledge/search") {
return handle_api_search_knowledge(method, path, body)
}
if str_eq(clean, "/api/neuron/knowledge/capture") {
return handle_api_capture_knowledge(body)
}
if str_eq(clean, "/api/neuron/knowledge/evolve") {
return handle_api_evolve_knowledge(body)
}
if str_eq(clean, "/api/neuron/knowledge/promote") {
return handle_api_promote_knowledge(body)
}
if str_eq(clean, "/api/neuron/processes") {
return handle_api_browse_processes(method, path, body)
}
if str_eq(clean, "/api/neuron/processes/define") {
return handle_api_define_process(body)
}
if str_eq(clean, "/api/neuron/state-events") {
return handle_api_log_state_event(body)
}
if str_eq(clean, "/api/neuron/config") {
return handle_api_inspect_config(path, body)
}
if str_eq(clean, "/api/neuron/config/tune") {
return handle_api_tune_config(body)
}
if str_eq(clean, "/api/neuron/graph") {
return handle_api_inspect_graph(method, path, body)
}
if str_eq(clean, "/api/neuron/graph/link") {
return handle_api_link_entities(body)
}
if str_eq(clean, "/api/neuron/memory") {
return handle_api_remember(body)
}
if str_eq(clean, "/api/safety-contact") {
return handle_safety_contact_post(body)
}
if str_eq(clean, "/api/neuron/node/create") {
return handle_api_node_create(body)
}
if str_eq(clean, "/api/neuron/node/update") {
return handle_api_node_update(body)
}
if str_eq(clean, "/api/neuron/node/delete") {
return handle_api_node_delete(body)
}
if str_eq(clean, "/api/neuron/memory/evolve") {
return handle_api_evolve_memory(body)
}
if str_eq(clean, "/api/neuron/memory/forget") {
return handle_api_forget(body)
}
if str_eq(clean, "/api/neuron/memory/delete") {
return handle_api_memory_delete(body)
}
if str_eq(clean, "/api/neuron/memory/update") {
return handle_api_memory_update(body)
}
if str_eq(clean, "/api/neuron/recall") {
return handle_api_recall(method, path, body)
}
if str_eq(clean, "/api/neuron/consolidate") {
return handle_api_consolidate(body)
}
if str_eq(clean, "/api/neuron/cultivate") {
return handle_api_cultivate(body)
}
if str_starts_with(clean, "/api/connectors") {
return handle_connectors(method, clean, body)
}
return err_404(clean)
}
if str_eq(method, "DELETE") {
// DELETE /api/sessions/:id delete a session and its history
if str_starts_with(clean, "/api/sessions/") {
let del_after: String = str_slice(clean, 14, str_len(clean))
let del_slash: Int = str_index_of(del_after, "/")
let del_id: String = if del_slash < 0 { del_after } else { str_slice(del_after, 0, del_slash) }
if !str_eq(del_id, "") {
return session_delete(del_id)
}
}
return err_404(clean)
}
if str_eq(method, "PATCH") {
// PATCH /api/sessions/:id update session title and/or folder
if str_starts_with(clean, "/api/sessions/") {
let patch_after: String = str_slice(clean, 14, str_len(clean))
let patch_slash: Int = str_index_of(patch_after, "/")
let patch_id: String = if patch_slash < 0 { patch_after } else { str_slice(patch_after, 0, patch_slash) }
if !str_eq(patch_id, "") {
return session_update_patch(patch_id, body)
}
}
return err_404(clean)
}
return err_405(method, clean)
}