Fix debug REPL and client hardening

ober

765b64a7ddbc66a6cc71566050552be9261408d7

diff --git a/.gitignore b/.gitignore
index 1d98ad6..f4cf582 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@ jcode.json
 /android/android.jar
 /android/build/
 /android/dist/
+/android/debug.keystore
 /android/app/src/main/assets/native/
 
 # Runtime data (checkpoints, security cache, logs)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3058c07..b2f71e5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -13,7 +13,7 @@
         android:icon="@drawable/ic_launcher"
         android:theme="@android:style/Theme.Material.Light.DarkActionBar"
         android:networkSecurityConfig="@xml/network_security_config"
-        android:debuggable="true"
+        android:debuggable="false"
         android:allowBackup="false">
 
         <activity
diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
index f12f07d..c75c3e6 100644
--- a/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt
@@ -89,7 +89,7 @@ class JcodeClient(private val context: Context) {
             return
         }
 
-        Thread {
+        readerThread = Thread {
             try {
                 Log.i(TAG, "connecting to $host:$port")
                 val s = Socket(host, port)
@@ -169,6 +169,10 @@ class JcodeClient(private val context: Context) {
         outbound.clear()
         reconnectThread?.interrupt()
         reconnectThread = null
+        writerThread?.interrupt()
+        writerThread = null
+        readerThread?.interrupt()
+        readerThread = null
         socket?.let {
             try { it.close() } catch (_: Exception) {}
         }
diff --git a/android/debug.keystore b/android/debug.keystore
deleted file mode 100644
index d0c7445..0000000
Binary files a/android/debug.keystore and /dev/null differ
diff --git a/src/jcode/core/debug-repl.ss b/src/jcode/core/debug-repl.ss
index 6334ce7..9ac3ef3 100644
--- a/src/jcode/core/debug-repl.ss
+++ b/src/jcode/core/debug-repl.ss
@@ -18,7 +18,8 @@
         stop-jcode-repl!
         jcode-repl-port)
 
-(import :jcode/core/log
+(import :jerboa/reader
+        :jcode/core/log
         :std/misc/thread)
 
 (def logger (make-logger "debug-repl"))
@@ -169,7 +170,7 @@
                                      op)
                                    (flush-output-port op)
                                    'continue])
-                          (read ip))))
+                          (jerboa-read ip))))
               (cond
                 ((eof-object? expr) (void))  ;; client disconnected
                 ((eq? expr 'continue) (loop))
diff --git a/src/jcode/core/session.ss b/src/jcode/core/session.ss
index 7593586..3e9979c 100644
--- a/src/jcode/core/session.ss
+++ b/src/jcode/core/session.ss
@@ -162,30 +162,46 @@
   ;; Atomically replace ALL stored messages for SESSION-ID with MSGS.
   ;; Used by /compact to swap older turns for an LLM-generated summary.
   (let* ((db (open-db))
-         (now (timestamp-now)))
-    (sqlite-exec db "BEGIN")
-    (sqlite-eval db "DELETE FROM messages WHERE session_id = ?" session-id)
-    (for-each
-      (lambda (msg)
-        (let ((tool-calls-json
-                (and (message-tool-calls msg)
-                     (json-object->string
-                       (map tool-call->stored-json (message-tool-calls msg))))))
-          (sqlite-eval db
-            "INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, created_at)
-             VALUES (?, ?, ?, ?, ?, ?)"
-            session-id
-            (message-role msg)
-            (let ((c (message-content msg))) (if (eq? c (void)) #f c))
-            tool-calls-json
-            (let ((id (message-tool-call-id msg))) (if (eq? id (void)) #f id))
-            now)))
-      msgs)
-    (sqlite-eval db
-      "UPDATE sessions SET updated_at = ? WHERE id = ?"
-      now session-id)
-    (sqlite-exec db "COMMIT")
-    (sqlite-close db)))
+         (now (timestamp-now))
+         (begun? #f)
+         (committed? #f))
+    (dynamic-wind
+      (lambda () (void))
+      (lambda ()
+        (try
+          (begin
+            (sqlite-exec db "BEGIN")
+            (set! begun? #t)
+            (sqlite-eval db "DELETE FROM messages WHERE session_id = ?" session-id)
+            (for-each
+              (lambda (msg)
+                (let ((tool-calls-json
+                        (and (message-tool-calls msg)
+                             (json-object->string
+                               (map tool-call->stored-json (message-tool-calls msg))))))
+                  (sqlite-eval db
+                    "INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, created_at)
+                     VALUES (?, ?, ?, ?, ?, ?)"
+                    session-id
+                    (message-role msg)
+                    (let ((c (message-content msg))) (if (eq? c (void)) #f c))
+                    tool-calls-json
+                    (let ((id (message-tool-call-id msg))) (if (eq? id (void)) #f id))
+                    now)))
+              msgs)
+            (sqlite-eval db
+              "UPDATE sessions SET updated_at = ? WHERE id = ?"
+              now session-id)
+            (sqlite-exec db "COMMIT")
+            (set! committed? #t)
+            (set! begun? #f))
+          (catch (e)
+            (when (and begun? (not committed?))
+              (try (sqlite-exec db "ROLLBACK")
+                (catch (_) (void))))
+            (error 'session-replace-messages "transaction failed" e))))
+      (lambda ()
+        (sqlite-close db)))))
 
 (def (session-update-title session-id title)
   (with-db
diff --git a/src/jcode/ui/cli.ss b/src/jcode/ui/cli.ss
index b690ec6..7ea1c47 100644
--- a/src/jcode/ui/cli.ss
+++ b/src/jcode/ui/cli.ss
@@ -71,7 +71,7 @@
     (load-config)
     (session-init-db)
     (init-tools)
-    ;; Start debug REPL if requested (--repl for auto-port, --repl-port N)
+    ;; Start debug REPL only when an explicit port is requested.
     (let ((repl-opt (assoc '--repl-port opts)))
       (when repl-opt
         (let ((port (start-jcode-repl! (cdr repl-opt))))
@@ -112,8 +112,17 @@
       ((equal? (car args) "--no-mcp")  (loop (cdr args) (cons '(--no-mcp . #t) opts)))
       ((equal? (car args) "--verbose") (loop (cdr args) (cons '(--verbose . #t) opts)))
       ((and (equal? (car args) "--repl-port") (pair? (cdr args)))
-       (loop (cddr args) (cons (cons '--repl-port (string->number (cadr args))) opts)))
-      ((equal? (car args) "--repl") (loop (cdr args) (cons '(--repl-port . 0) opts)))
+       (let ((p (string->number (cadr args))))
+         (if (and p (> p 0) (< p 65536))
+           (loop (cddr args) (cons (cons '--repl-port p) opts))
+           (begin
+             (fprintf (current-error-port) "[ERROR] invalid --repl-port: ~a~n" (cadr args))
+             (exit 1)))))
+      ((equal? (car args) "--repl")
+       (begin
+         (fprintf (current-error-port)
+           "[ERROR] --repl was removed; use --repl-port N to enable the debug REPL.~n")
+         (exit 1)))
       ((and (equal? (car args) "--trace") (pair? (cdr args)))
        (loop (cddr args) (cons (cons '--trace (cadr args)) opts)))
       ((and (equal? (car args) "--model") (pair? (cdr args)))
@@ -167,8 +176,7 @@ OPTIONS:
     --tui            Launch terminal UI mode
     --no-tui         Force line-mode REPL (default)
     --no-mcp         Skip MCP server initialization
-    --repl           Start debug REPL on auto-assigned port
-    --repl-port N    Start debug REPL on specific port
+    --repl-port N    Start debug REPL on specific localhost port
     --verbose        Log TUI events to ~/jcode.log
     --trace FILE     Trace EVERYTHING to FILE: full HTTP requests/responses
                      (API keys redacted), full tool args/results, all log
diff --git a/src/jcode/ui/connect.ss b/src/jcode/ui/connect.ss
index ddfbf56..b2cdac2 100644
--- a/src/jcode/ui/connect.ss
+++ b/src/jcode/ui/connect.ss
@@ -25,9 +25,14 @@
 (def (token-path)
   (path-join (or (getenv "HOME") ".") ".jcode" "relay-token"))
 
+(def (secure-token-file! path)
+  (try (chmod path #o600)
+    (catch (_) (void))))
+
 (def (load-token)
   (let ((p (token-path)))
     (and (file-exists? p)
+         (begin (secure-token-file! p) #t)
          (call-with-input-file p
            (lambda (port)
              (let ((s (get-string-all port)))
diff --git a/src/jcode/ui/relay.ss b/src/jcode/ui/relay.ss
index 789a308..8d5bd2f 100644
--- a/src/jcode/ui/relay.ss
+++ b/src/jcode/ui/relay.ss
@@ -73,9 +73,14 @@
 (def (token-path)
   (path-join (or (getenv "HOME") ".") ".jcode" "relay-token"))
 
+(def (secure-path! path mode)
+  (try (chmod path mode)
+    (catch (_) (void))))
+
 (def (load-token)
   (let ((p (token-path)))
     (and (file-exists? p)
+         (begin (secure-path! p #o600) #t)
          (call-with-input-file p
            (lambda (port)
              (let ((s (get-string-all port)))
@@ -107,10 +112,12 @@
       (let ((dir (path-join (or (getenv "HOME") ".") ".jcode")))
         (unless (file-exists? dir)
           (mkdir dir))
+        (secure-path! dir #o700)
         (let* ((tok (random-hex-32))
                (path (token-path)))
           (call-with-output-file path
             (lambda (out) (display tok out)))
+          (secure-path! path #o600)
           (fprintf (current-error-port)
             "[INFO] generated new relay token~n")
           (fprintf (current-error-port)
diff --git a/src/jcode/ui/serve.ss b/src/jcode/ui/serve.ss
index 4b969bf..beb0d43 100644
--- a/src/jcode/ui/serve.ss
+++ b/src/jcode/ui/serve.ss
@@ -53,6 +53,8 @@
 
 (def logger (make-logger "serve"))
 
+(def *auth-timeout-secs* 10)
+
 ;; Tools hidden when mode = "plan".
 (def *mutating-tools*
   '("write" "edit" "multi-edit" "patch" "bash" "git_commit"))
@@ -465,10 +467,15 @@
 (def (token-path)
   (path-join (or (getenv "HOME") ".") ".jcode" "server-token"))
 
+(def (secure-path! path mode)
+  (try (chmod path mode)
+    (catch (_) (void))))
+
 (def (ensure-jcode-dir!)
   (let ((dir (path-join (or (getenv "HOME") ".") ".jcode")))
     (unless (file-exists? dir)
-      (mkdir dir))))
+      (mkdir dir))
+    (secure-path! dir #o700)))
 
 (def (bytes->hex bv)
   (let ((len (bytevector-length bv)))
@@ -484,13 +491,16 @@
   (ensure-jcode-dir!)
   (let ((token (bytes->hex (random-bytes 32))))
     (write-file-string (token-path) token)
+    (secure-path! (token-path) #o600)
     token))
 
 (def (load-token)
   "Load the server token from disk. Returns string or #f."
   (let ((path (token-path)))
     (if (file-exists? path)
-      (string-trim (read-file-string path))
+      (begin
+        (secure-path! path #o600)
+        (string-trim (read-file-string path)))
       #f)))
 
 (def (ensure-token!)
@@ -534,6 +544,21 @@
            (emit-error (format "auth error: ~a" (err->string e)))
            #f))))))
 
+(def (start-auth-timeout! in out label)
+  "Close unauthenticated connections that do not send the auth line promptly."
+  (let ((done (cons #f #f)))
+    (spawn
+      (lambda ()
+        (thread-sleep! *auth-timeout-secs*)
+        (unless (car done)
+          (log-warn logger "auth-timeout" `((label . ,label)))
+          (try (close-port in) (catch (_) (void)))
+          (try (close-port out) (catch (_) (void))))))
+    done))
+
+(def (finish-auth-timeout! done)
+  (when done (set-car! done #t)))
+
 ;; ── reverse-tunnel client (host side) ────────────────────────────────
 ;;
 ;; Dial a relay, register as a host under NAME, wait for a controller to be
@@ -615,8 +640,11 @@
                (set! *session-tokens-in* 0)
                (set! *session-tokens-out* 0)
                (set! *session-cost* 0.0)
-               (when (validate-auth! server-token)
-                 (serve-loop)))))
+               (let ((auth-timeout (start-auth-timeout! sin sout "relay")))
+                 (try
+                   (when (validate-auth! server-token)
+                     (serve-loop))
+                   (finally (finish-auth-timeout! auth-timeout)))))))
           (finally
             (try (close-port sin) (catch (_) (void)))
             (try (close-port sout) (catch (_) (void))))))
@@ -659,9 +687,12 @@
             (set! *session-tokens-in* 0)
             (set! *session-tokens-out* 0)
             (set! *session-cost* 0.0)
-            (if (validate-auth! token)
-              (serve-loop)
-              (log-warn logger "tcp-reject" '((msg . "auth failed, closing")))))
+            (let ((auth-timeout (start-auth-timeout! in out "tcp")))
+              (try
+                (if (validate-auth! token)
+                  (serve-loop)
+                  (log-warn logger "tcp-reject" '((msg . "auth failed, closing"))))
+                (finally (finish-auth-timeout! auth-timeout)))))
           (catch (e)
             (log-error logger "tcp-error" `((msg . ,(err->string e)))))
           (finally
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 3495136..852a107 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -218,10 +218,6 @@
   ;; NOTE: cli-main already called load-config, session-init-db, init-tools
   ;; before dispatching here — do NOT re-init.
   (apply-tui-overrides! args)
-  ;; Skip if --repl-port already started a REPL — otherwise we stomp the user's port.
-  (unless (jcode-repl-port)
-    (try (start-jcode-repl!)
-      (catch (e) (void))))
   (let ((verbose? (and (member "--verbose" args) #t)))
     (when verbose? (open-tui-log!))
     (tui-log "tui-main: starting, args=~a" args)