Commit ddae723

mo khan <mo@mokhan.ca>
2026-09-12 05:27:22
fix: correlate prompt/answer by id, close /answer pre-approval bug
Completes the fix elelem's llama-driven attempts left partially done: - ask() now creates+registers the queue and reads the id inside one mutex critical section (elelem's version had a read-modify-write race on @prompt_counter outside the lock, and separately never guarded ask()'s write to @prompts at all, only answer()'s). - askPermission(id, text) now takes the server-issued id directly instead of inventing one client-side or back-filling it via a querySelectorAll(".prompt") DOM lookup after the fact. - emit/broadcast compact away id: nil so pre-existing non-prompt event specs are unaffected. - Rewrote the #ask spec (it asserted the old, intentionally-insecure single-Queue API) and added two specs for the actual security property: a mismatched-id answer is ignored, and an answer sent before any prompt cannot pre-approve the next one. Dogfood checkpoint only. Not pushed. main is untouched. Server still binds 0.0.0.0 with no auth and /message has no single-flight guard — not safe to expose.
1 parent e9ac050
Changed files (3)
lib
spec
lib/elelem/server/web_terminal.rb
@@ -23,10 +23,10 @@ module Elelem
       end
 
       def ask(prompt)
-        id = @prompt_counter += 1
+        queue = Queue.new
+        id = @mutex.synchronize { @prompts[@prompt_counter += 1] = queue; @prompt_counter }
         emit("prompt", prompt, id: id)
-        @prompts[id] = Queue.new
-        @prompts[id].pop
+        queue.pop
       end
 
       def thinking(text)
@@ -59,9 +59,11 @@ module Elelem
       end
 
       def answer(id, text)
-        if @prompts.key?(id)
-          @prompts[id] << text
-          @prompts.delete(id)
+        @mutex.synchronize do
+          if @prompts.key?(id)
+            @prompts[id] << text
+            @prompts.delete(id)
+          end
         end
       end
 
@@ -75,7 +77,7 @@ module Elelem
         plain = text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
         return if plain.strip.empty?
 
-        broadcast(type: type, text: plain, id: id)
+        broadcast({ type: type, text: plain, id: id }.compact)
       end
 
       def broadcast(event)
lib/elelem/server.rb
@@ -173,11 +173,12 @@ module Elelem
         scroll(was);
       }
 
-      function askPermission(text) {
+      function askPermission(id, text) {
         current = null;
         const was = atBottom();
         const el = document.createElement("div");
         el.className = "prompt";
+        el.dataset.id = id;
         el.innerHTML = "<div>" + escape(text) + "</div>";
         const respond = (answer) => {
           el.querySelectorAll("button").forEach((b) => b.remove());
@@ -195,15 +196,13 @@ module Elelem
         el.append(allow, deny);
         log.appendChild(el);
         scroll(was);
-        // Store the ID for this prompt
-        el.dataset.id = Date.now() + Math.random();
       }
 
       new EventSource("/events").onmessage = (e) => {
         const event = JSON.parse(e.data);
         if (event.type === "content") append("assistant", event.text);
         else if (event.type === "thinking") append("thinking", event.text);
-        else if (event.type === "prompt") askPermission(event.text);
+        else if (event.type === "prompt") askPermission(event.id, event.text);
         else if (event.type === "done") current = null;
       };
 
spec/elelem/server/web_terminal_spec.rb
@@ -101,10 +101,29 @@ RSpec.describe Elelem::Server::WebTerminal do
   end
 
   describe "#ask" do
-    it "emits a prompt and blocks until answered" do
+    it "emits a prompt with an id and blocks until answered with a matching id" do
       thread = Thread.new { subject.ask("Allow?") }
-      expect(events.pop).to eq({ type: "prompt", text: "Allow?" })
-      subject.answer("n")
+      event = events.pop
+      expect(event).to include(type: "prompt", text: "Allow?")
+      subject.answer(event[:id], "n")
+      expect(thread.value).to eq("n")
+    end
+
+    it "ignores an answer for an id that isn't pending" do
+      thread = Thread.new { subject.ask("Allow?") }
+      event = events.pop
+      subject.answer(event[:id] + 1, "n")
+      subject.answer(event[:id], "y")
+      expect(thread.value).to eq("y")
+    end
+
+    it "does not let an answer sent before any prompt pre-approve the next prompt" do
+      subject.answer(1, "y")
+
+      thread = Thread.new { subject.ask("Allow?") }
+      event = events.pop
+      subject.answer(event[:id], "n")
+
       expect(thread.value).to eq("n")
     end
   end