Compare commits

..

6 Commits

Author SHA1 Message Date
Tim Lingo 70646f867c fix(chat): agent write_file/edit_file no longer return false success receipts (BUG-29)
Root cause: dispatch_tool's write_file returned {"ok":true} without checking
fs_write's result, and edit_file returned ok:true even when old_text was absent
(str_replace silently no-ops) and its fs_write was also unchecked. Any failed
or no-op write fed the model a false success receipt, which it then repeated
to the user as fact.

The change (Receipt Contract rule 1 — a tool result must reflect what actually
happened):
- write_file: check fs_write's return (1 = all bytes written, 0 = fail);
  on failure return {"error":"write failed"} in the handler's existing
  error-JSON shape.
- edit_file: reject empty old_text, verify old_text is actually present
  (str_contains) before replacing, and check the fs_write result the same way.
- Verification is by operation result, NOT an fs_read read-back: fs_read arms
  the runtime's one-shot binary send length, the exact mechanism that truncated
  the safety-contact response (#96). Same honest-write pattern as that fix.

E2E evidence (sandboxed elb build, dispatch_tool driven directly):
- unpatched: write_file into a chmod-000 dir -> {"ok":true} (lie);
  edit_file with absent old_text -> {"ok":true} (lie, file untouched)
- patched:   same calls -> {"error":"write failed"} /
  {"error":"old_text not found in file"}; happy paths still ok:true
- scripts/verify-soul-contract.sh on the patched soul: GATE PASS (27/27
  presence + immutability)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:19:09 -05:00
will.anderson b784750f69 Merge pull request 'Fix truncated /api/safety-contact response (988 crisis-line)' (#96) from fix/safety-contact-truncation into hotfix/elc-source-typos 2026-07-21 17:16:13 +00:00
will.anderson a45a3ca379 Fix truncated POST/GET /api/safety-contact response
Saving the 988 crisis-line contact returned truncated, unparseable JSON —
cut mid-"set_at" at the file's byte length (e.g. 178 of a 218-byte
response). The contact written to disk was complete; only the HTTP response
was clipped, so a real customer's crisis-contact save came back corrupt.

Root cause is in the el runtime's response writer, not a handler buffer:
fs_read stores the file's byte count in a thread-local (_tl_fs_read_len)
for binary-safe file serving, and the response writer uses that length when
non-zero instead of strlen(body) (el_runtime.c:1409). Both safety-contact
handlers call fs_read (the POST read-back verify; the GET file read) and
then return a LONGER wrapped JSON string, so the response is capped to the
file size.

Soul-source fix (no runtime change needed):
- POST: verify persistence via fs_write's return (1 = all bytes written)
  instead of an fs_read read-back — removes the fs_read, so nothing caps the
  response.
- GET: fs_read is required, so reset the thread-local after it with a no-op
  fs_read("") (fs_read zeroes the length before it opens a path) so the
  wrapped response is sent in full.

Verified: POST (crisis-line + custom) and GET now return complete, valid
JSON (parses cleanly, full contact incl. set_at). Regenerated dist/soul.c +
dist/safety.c (3GB RSS watchdog, release el_runtime v1.0.0-20260501).
Full suite still green: verify-soul-contract GATE PASS (PRESENCE +
IMMUTABILITY), genesis boot survives (/health 200, no segfault), bounded-
persona floor still compiled in.

NOTE: the underlying runtime leak (any handler that fs_reads then returns a
longer string) is worth a proper fix in el_runtime.c (use the max of
strlen and _tl_fs_read_len) so this class can't recur.
2026-07-21 12:13:58 -05:00
will.anderson 9387c57c3b Merge pull request 'Fix #150: fresh-install genesis boot SIGSEGV in mem_save' (#95) from fix/genesis-boot-crash into hotfix/elc-source-typos 2026-07-21 16:53:52 +00:00
will.anderson 091cc1fc0e Fix issue #150: fresh-install genesis boot SIGSEGV in mem_save
A fresh-install (SOUL_CGI_ID=ntn-genesis) boot crashed with
"Segmentation fault: 11" right after the http server came up — a real
customer's very first boot. Backtrace:

  strcmp(0x1) <- str_eq (el_runtime.c:219) <- mem_save <- awareness_run

Root cause: the el runtime's engram_save returns an Int (1 = ok, 0 =
failure), but mem_save did `str_eq(engram_save(path), "")`, treating the
return as a String. str_eq runs EL_CSTR on it, which is a raw cast:
EL_CSTR(1) = (char*)0x1. On a SUCCESSFUL save (return 1) strcmp then
dereferences 0x1 and segfaults. Genesis is the first path that both seeds
the brain AND saves it successfully on the very first awareness pass, so it
crashes there; non-genesis boots (contract gate, refusal test) don't hit a
successful early mem_save, which is why they passed. handle_api_consolidate
had the identical latent bug.

Fix: read engram_save's Int result and compare `== 0` instead of str_eq'ing
it — in mem_save (memory.el) and handle_api_consolidate (neuron-api.el).

Regression: pre-existing, NOT introduced by the immutability/floor rebuild.
The pre-immutability build (1442ce2) genesis-crashes identically in the same
unchanged mem_save; #159 never actually fixed #150 for a release-runtime
build.

Regenerated dist/soul.c + per-module dist/{memory,neuron-api}.c (3GB RSS
watchdog, built against release el_runtime v1.0.0-20260501). Verified:
genesis boot survives (/health 200, no segfault), verify-soul-contract.sh
GATE PASS (PRESENCE + IMMUTABILITY), and the bounded-persona floor is still
compiled in (BOUNDED PERSONA / SOUL_PERSONA_NAME strings present).
2026-07-21 11:50:59 -05:00
will.anderson 9a491a8e6d Merge pull request 'Immutability fix (tombstone/supersede) on the launch branch' (#94) from fix/immutable-on-hotfix into hotfix/elc-source-typos 2026-07-21 16:05:15 +00:00
8 changed files with 63 additions and 24 deletions
+26 -2
View File
@@ -1706,7 +1706,17 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
if !path_within_root(path, root) {
return json_safe("denied: path is outside the agent workspace root")
}
fs_write(resolve_in_root(path, root), content)
// BUG-29 (Receipt Contract rule 1): fs_write's result was ignored, so a
// failed write (missing/unwritable dir, disk full) still returned
// {"ok":true} a false receipt fed straight to the model. Check the
// return (1 = all bytes written, 0 = fail), the same honest-write pattern
// as the safety-contact fix. A read-back via fs_read is deliberately NOT
// used here: fs_read arms the runtime's one-shot binary send length,
// which truncated longer HTTP responses (the #96 failure mode).
let write_ok: Int = fs_write(resolve_in_root(path, root), content)
if write_ok == 0 {
return json_safe("{\"error\":\"write failed\"}")
}
return json_safe("{\"ok\":true}")
}
if str_eq(tool_name, "web_get") {
@@ -1784,8 +1794,22 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
if str_eq(content, "") {
return json_safe("{\"error\":\"file not found\"}")
}
// BUG-29 (Receipt Contract rule 1): when old_text was absent (or empty),
// str_replace was a silent no-op and the handler still claimed ok:true
// a false receipt. Verify the text is actually present before replacing.
if str_eq(old_text, "") {
return json_safe("{\"error\":\"old_text is required\"}")
}
if !str_contains(content, old_text) {
return json_safe("{\"error\":\"old_text not found in file\"}")
}
let updated: String = str_replace(content, old_text, new_text)
fs_write(resolved, updated)
// BUG-29: the fs_write result was also unchecked a failed write still
// returned ok:true. Same honest-write check as write_file above.
let write_ok: Int = fs_write(resolved, updated)
if write_ok == 0 {
return json_safe("{\"error\":\"write failed\"}")
}
return json_safe("{\"ok\":true}")
}
if str_eq(tool_name, "remember") {
Generated Vendored
+2 -2
View File
@@ -120,8 +120,8 @@ el_val_t mem_consolidate(void) {
}
el_val_t mem_save(el_val_t path) {
el_val_t save_result = engram_save(path);
if (str_eq(save_result, EL_STR(""))) {
el_val_t saved = engram_save(path);
if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete")));
}
return 0;
Generated Vendored
+2 -2
View File
@@ -744,8 +744,8 @@ el_val_t handle_api_consolidate(el_val_t body) {
el_val_t summary = json_get(body, EL_STR("summary"));
el_val_t snap = state_get(EL_STR("soul_snapshot_path"));
if (!str_eq(snap, EL_STR(""))) {
el_val_t save_result = engram_save(snap);
if (str_eq(save_result, EL_STR(""))) {
el_val_t saved = engram_save(snap);
if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync")));
}
}
Generated Vendored
+3 -3
View File
@@ -340,6 +340,7 @@ el_val_t handle_safety_contact_get(void) {
if (str_eq(raw, EL_STR(""))) {
return EL_STR("{\"configured\":false}");
}
el_val_t _reset = fs_read(EL_STR(""));
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}"));
return 0;
}
@@ -359,9 +360,8 @@ el_val_t handle_safety_contact_post(el_val_t body) {
el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; });
el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ"));
el_val_t contact_json = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}"));
fs_write(safety_contact_path(), contact_json);
el_val_t check = fs_read(safety_contact_path());
if (str_eq(check, EL_STR(""))) {
el_val_t write_ok = fs_write(safety_contact_path(), contact_json);
if (write_ok == 0) {
return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}");
}
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}"));
Generated Vendored
+7 -7
View File
@@ -25383,8 +25383,8 @@ el_val_t mem_consolidate(void) {
}
el_val_t mem_save(el_val_t path) {
el_val_t save_result = engram_save(path);
if (str_eq(save_result, EL_STR(""))) {
el_val_t saved = engram_save(path);
if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[memory] mem_save: engram_save failed for "), path), EL_STR(" \xe2\x80\x94 snapshot may be incomplete")));
}
return 0;
@@ -25754,6 +25754,7 @@ el_val_t handle_safety_contact_get(void) {
if (str_eq(raw, EL_STR(""))) {
return EL_STR("{\"configured\":false}");
}
el_val_t _reset = fs_read(EL_STR(""));
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), raw), EL_STR("}"));
return 0;
}
@@ -25773,9 +25774,8 @@ el_val_t handle_safety_contact_post(el_val_t body) {
el_val_t crisis_str = ({ el_val_t _if_result_51 = 0; if (is_crisis) { _if_result_51 = (EL_STR("true")); } else { _if_result_51 = (EL_STR("false")); } _if_result_51; });
el_val_t now = time_format(time_now(), EL_STR("%Y-%m-%dT%H:%M:%SZ"));
el_val_t contact_json = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"name\":\""), json_safe(name)), EL_STR("\"")), EL_STR(",\"contact_method\":\"")), json_safe(method)), EL_STR("\"")), EL_STR(",\"contact_value\":\"")), json_safe(value)), EL_STR("\"")), EL_STR(",\"relationship\":\"")), json_safe(rel)), EL_STR("\"")), EL_STR(",\"confirmed\":true")), EL_STR(",\"is_crisis_line\":")), crisis_str), EL_STR(",\"set_at\":\"")), now), EL_STR("\"}"));
fs_write(safety_contact_path(), contact_json);
el_val_t check = fs_read(safety_contact_path());
if (str_eq(check, EL_STR(""))) {
el_val_t write_ok = fs_write(safety_contact_path(), contact_json);
if (write_ok == 0) {
return EL_STR("{\"ok\":false,\"error\":\"write_failed\"}");
}
return el_str_concat(el_str_concat(EL_STR("{\"configured\":true,\"contact\":"), contact_json), EL_STR(",\"ok\":true}"));
@@ -29336,8 +29336,8 @@ el_val_t handle_api_consolidate(el_val_t body) {
el_val_t summary = json_get(body, EL_STR("summary"));
el_val_t snap = state_get(EL_STR("soul_snapshot_path"));
if (!str_eq(snap, EL_STR(""))) {
el_val_t save_result = engram_save(snap);
if (str_eq(save_result, EL_STR(""))) {
el_val_t saved = engram_save(snap);
if (saved == 0) {
println(el_str_concat(el_str_concat(EL_STR("[api] consolidate: engram_save failed for "), snap), EL_STR(" \xe2\x80\x94 snapshot may be out of sync")));
}
}
+6 -2
View File
@@ -133,8 +133,12 @@ fn mem_consolidate() -> String {
}
fn mem_save(path: String) -> Void {
let save_result: String = engram_save(path)
if str_eq(save_result, "") {
// engram_save returns an Int (1 = ok, 0 = failure), NOT a String. Calling
// str_eq on it casts EL_CSTR(1) -> (char*)0x1 and SIGSEGVs on a SUCCESSFUL
// save which is exactly what a fresh-install genesis boot does first
// (seeds the brain, saves, crashes). This is issue #150. Check the Int.
let saved: Int = engram_save(path)
if saved == 0 {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
}
}
+4 -2
View File
@@ -725,8 +725,10 @@ fn handle_api_consolidate(body: String) -> String {
let summary: String = json_get(body, "summary")
let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") {
let save_result: String = engram_save(snap)
if str_eq(save_result, "") {
// engram_save returns an Int (1 = ok, 0 = failure); str_eq on it derefs
// EL_CSTR(1)=0x1 and SIGSEGVs on success (issue #150). Check the Int.
let saved: Int = engram_save(snap)
if saved == 0 {
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
}
}
+13 -4
View File
@@ -438,6 +438,12 @@ fn safety_contact_path() -> String {
fn handle_safety_contact_get() -> String {
let raw: String = fs_read(safety_contact_path())
if str_eq(raw, "") { return "{\"configured\":false}" }
// fs_read set the runtime's binary-safe send length to len(raw); the HTTP
// response writer uses that length when non-zero, which would TRUNCATE this
// wrapped (longer) response to len(raw). Reset it with a no-op read of a
// missing path (fs_read zeroes the length before it opens) so the full
// response is sent.
let _reset: String = fs_read("")
return "{\"configured\":true,\"contact\":" + raw + "}"
}
@@ -463,9 +469,12 @@ fn handle_safety_contact_post(body: String) -> String {
+ ",\"confirmed\":true"
+ ",\"is_crisis_line\":" + crisis_str
+ ",\"set_at\":\"" + now + "\"}"
fs_write(safety_contact_path(), contact_json)
// Read-back verify the write actually persisted.
let check: String = fs_read(safety_contact_path())
if str_eq(check, "") { return "{\"ok\":false,\"error\":\"write_failed\"}" }
// Verify persistence via fs_write's return (1 = all bytes written, 0 = fail).
// The previous fs_read read-back set the runtime's binary-safe send length to
// the file size, which then TRUNCATED this longer JSON response to that size
// (the safety-contact 988 response was cut mid-"set_at"). Checking the write
// return avoids the fs_read entirely, so the full response is sent.
let write_ok: Int = fs_write(safety_contact_path(), contact_json)
if write_ok == 0 { return "{\"ok\":false,\"error\":\"write_failed\"}" }
return "{\"configured\":true,\"contact\":" + contact_json + ",\"ok\":true}"
}