external-generated-code + tui: /ask-grok + /grok tab (add-grok phase 6)

ober

43105b1c6f9b526fa4e3eddef3bdd8dbbcecfab7

diff --git a/src/jcode/tool/external-llm.ss b/src/jcode/tool/external-llm.ss
index 3f5ee65..910909d 100644
--- a/src/jcode/tool/external-llm.ss
+++ b/src/jcode/tool/external-llm.ss
@@ -24,13 +24,21 @@
         ext-result-tokens-out
         ext-result-cost-usd
         external-llm-providers
-        make-external-session-id)
+        make-external-session-id
+        ;; Exported for hermetic unit tests of grok-related plumbing —
+        ;; sandbox path lists and the Grok JSON parser are security-relevant,
+        ;; so we want them under test even though they are nominally
+        ;; "internal" helpers.
+        provider-auth-paths
+        sensitive-deny-paths
+        parse-grok-json)
 
 (import :jerboa/core
         :jerboa/runtime
         :std/os/path
         :std/os/platform
         :std/os/sandbox
+        :std/os/temp     ;; P3.1: mkstemp-backed temp files (no predictable /tmp paths)
         :std/misc/ports
         :std/misc/string
         :std/misc/uuid
@@ -43,7 +51,7 @@
   (or (getenv "HOME") "/"))
 
 (def (external-llm-providers)
-  '(claude gemini codex opencode))
+  '(claude gemini codex opencode grok))
 
 ;; (label argv-builder cli-auth-paths)
 ;;   argv-builder : prompt-string -> list-of-strings (full argv incl. exe)
@@ -87,6 +95,16 @@
            (list (path-join (home) ".config/opencode")
                  (path-join (home) ".local/share/opencode")
                  (path-join (home) ".cache/opencode"))))
+    ((grok)
+     ;; grok CLI is the xAI agentic CLI (~/.grok session token). Flags per
+     ;; docs/add-grok.md; if local testing finds different ones, adjust here.
+     (list "grok"
+           (lambda (prompt)
+             (list "grok" "-p" prompt
+                   "--output-format" "plain"
+                   "--permission-mode" "bypassPermissions"))
+           (list (path-join (home) ".grok")
+                 (path-join (home) ".config/grok"))))
     (else #f)))
 
 ;; POSIX single-quote escape: anything between single quotes is literal
@@ -136,6 +154,9 @@
      (list (path-join (home) ".config/opencode")
            (path-join (home) ".local/share/opencode")
            (path-join (home) ".cache/opencode")))
+    ((grok)
+     (list (path-join (home) ".grok")
+           (path-join (home) ".config/grok")))
     (else '())))
 
 (def (sensitive-deny-paths chosen)
@@ -194,7 +215,9 @@
          (chosen   (string->symbol label))
          (argv     (build prompt))
          (cwd      (current-directory))
-         (tmp-out  (path-join "/tmp" (format "jcode-ask-~a.log" label)))
+         ;; P3.1: mkstemp gives an unguessable, atomic-create path (mode 0600)
+         ;; so a same-uid attacker can't pre-seed a symlink at the target.
+         (tmp-out  (make-temporary-file (format "/tmp/jcode-ask-~a-XXXXXX" label)))
          (cmd      (build-cmdline argv tmp-out))
          ;; Linux/BSD path lists (Landlock / Capsicum / unveil consume
          ;; these directly). macOS branch builds its own SBPL below.
@@ -319,6 +342,23 @@
                  (path-join (home) ".cache/opencode"))
            parse-opencode-jsonl
            session-id))
+    ((grok)
+     ;; grok session: `-s <session-id>` (or `--resume`) per docs/add-grok.md.
+     ;; First turn: no session id; we self-assign one for the tab and let
+     ;; the parser overwrite from the CLI's sessionId field if it provides
+     ;; a different one.
+     (let ((eff-sid (or (nonempty-string session-id) (make-external-session-id))))
+       (list "grok"
+             (append (list "grok" "-p" prompt)
+                     (if (nonempty-string session-id)
+                       (list "-s" eff-sid)
+                       (list "-s" eff-sid))
+                     (list "--output-format" "json"
+                           "--permission-mode" "bypassPermissions"))
+             (list (path-join (home) ".grok")
+                   (path-join (home) ".config/grok"))
+             parse-grok-json
+             eff-sid)))
     (else #f)))
 
 (def (ask-external-llm-session provider prompt session-id)
@@ -348,7 +388,8 @@
          (sid-fallback (or eff-sid session-id))
          (chosen   (string->symbol label))
          (cwd      (current-directory))
-         (tmp-out  (path-join "/tmp" (format "jcode-tab-~a.log" label)))
+         ;; P3.1: see run-with-spec — mkstemp instead of predictable /tmp path.
+         (tmp-out  (make-temporary-file (format "/tmp/jcode-tab-~a-XXXXXX" label)))
          (cmd      (build-cmdline argv tmp-out))
          (read-paths
            (list "/usr" "/bin" "/sbin" "/etc" "/opt" "/Library" "/System"
@@ -526,3 +567,30 @@
                   session-id-in))
          (final (or (find-last-message-text events) text)))
     (make-ext-result final #f sid 0 0 0.0)))
+
+(def (parse-grok-json text session-id-in)
+  ;; grok --output-format=json produces an object with "text",
+  ;; "stopReason", "sessionId", "requestId" (per docs/add-grok.md). Usage
+  ;; field shape unknown — fall back to (0 0) and 0.0 cost when absent.
+  (let ((parsed (safe-parse-json text)))
+    (cond
+      ((not (hash-table? parsed))
+       (make-ext-result text #f session-id-in 0 0 0.0))
+      (else
+       (let* ((result-text (or (nonempty-string (hash-ref parsed "text" #f))
+                               (nonempty-string (hash-ref parsed "result" #f))
+                               text))
+              (sid (or (nonempty-string (hash-ref parsed "sessionId" #f))
+                       (nonempty-string (hash-ref parsed "session_id" #f))
+                       session-id-in))
+              (usage (hash-ref parsed "usage" #f))
+              (in-tok (if (hash-table? usage)
+                        (or (hash-ref usage "input_tokens" #f)
+                            (hash-ref usage "inputTokens" 0)) 0))
+              (out-tok (if (hash-table? usage)
+                         (or (hash-ref usage "output_tokens" #f)
+                             (hash-ref usage "outputTokens" 0)) 0)))
+         (make-ext-result result-text #f sid
+                          (if (number? in-tok) in-tok 0)
+                          (if (number? out-tok) out-tok 0)
+                          0.0))))))
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 7e6b9e6..bf3e88c 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -531,10 +531,12 @@
                "  /ask-gemini    Second opinion from gemini CLI (sandboxed)"
                "  /ask-codex     Second opinion from codex CLI (sandboxed)"
                "  /ask-opencode  Second opinion from opencode CLI (sandboxed)"
+               "  /ask-grok      Second opinion from grok CLI (sandboxed)"
                "  /claude [prompt]    Open/focus a Claude Code tab (sessioned)"
                "  /codex [prompt]     Open/focus a Codex tab (sessioned)"
                "  /gemini [prompt]    Open/focus a Gemini CLI tab (sessioned)"
                "  /opencode [prompt]  Open/focus an opencode tab (sessioned)"
+               "  /grok [prompt]      Open/focus a Grok CLI tab (sessioned)"
                "  /tabs               List open tabs"
                "  /jcode              Switch back to the main jcode tab"
                "  /close-tab          Close the current external tab"
@@ -606,7 +608,8 @@
       ((or (equal? cmd "ask-claude")
            (equal? cmd "ask-gemini")
            (equal? cmd "ask-codex")
-           (equal? cmd "ask-opencode"))
+           (equal? cmd "ask-opencode")
+           (equal? cmd "ask-grok"))
        (handle-ask-external! state
          (string->symbol (substring cmd 4 (string-length cmd)))))
       ((equal? cmd "expert")
@@ -682,6 +685,10 @@
        (let ((rest (if (equal? cmd "opencode") ""
                      (string-trim (substring cmd 9 (string-length cmd))))))
          (open-or-focus-external-tab! state 'opencode rest)))
+      ((or (equal? cmd "grok") (string-prefix? "grok " cmd))
+       (let ((rest (if (equal? cmd "grok") ""
+                     (string-trim (substring cmd 5 (string-length cmd))))))
+         (open-or-focus-external-tab! state 'grok rest)))
       (#t
        ;; Slash dispatch: /<name> [args] runs a skill. Builtins win over
        ;; file-based skills (so jcode's jerboa-mcp workflow is always
@@ -1789,7 +1796,7 @@
 ;; fields; non-active tabs are snapshotted into app-state-tabs. Switch
 ;; semantics: capture-then-restore.
 
-(def *external-providers* '(claude codex gemini opencode))
+(def *external-providers* '(claude codex gemini opencode grok))
 
 (def (current-tab-provider state)
   ;; Returns provider symbol of the active tab, or #f for main.
diff --git a/test/run.ss b/test/run.ss
index 6ad14a4..de3d59e 100644
--- a/test/run.ss
+++ b/test/run.ss
@@ -11,6 +11,7 @@
         (jcode tool registry)
         (jcode tool file)
         (jcode tool bash)
+        (jcode tool external-llm)
         (jcode guardrails nudge)
         (jcode guardrails error-tracker)
         (jcode guardrails message-type)
@@ -1783,6 +1784,72 @@
 (check-pred! "*known-providers* still contains anthropic"
              *known-providers* (lambda (l) (assoc "anthropic" l)))
 
+;; ── external-llm: /ask-grok + sandbox paths ───────────────────────
+;; The Grok CLI integration adds:
+;;   - grok to (external-llm-providers)
+;;   - ~/.grok writable for the grok provider; denied for every other one
+;;   - parse-grok-json reads text + sessionId out of `grok -p ... --json` output
+;; Tests do NOT spawn the CLI (it may not be installed); they drive the pure
+;; helpers we exported for this purpose.
+
+(section "=== external-llm: grok ===")
+
+(check-pred! "external-llm-providers includes grok"
+  (external-llm-providers) (lambda (l) (memq 'grok l)))
+(check-pred! "external-llm-providers still includes claude"
+  (external-llm-providers) (lambda (l) (memq 'claude l)))
+
+;; Sandbox: grok's own auth paths cover ~/.grok.
+(check-pred! "grok auth paths include ~/.grok"
+  (provider-auth-paths 'grok)
+  (lambda (ps)
+    (let lp ([p ps])
+      (cond [(null? p) #f]
+            [(str-contains? (car p) ".grok") #t]
+            [else (lp (cdr p))]))))
+
+;; Sandbox: when grok is the chosen CLI, its own ~/.grok is NOT denied,
+;; but every other CLI's auth dir IS.
+(define grok-deny (sensitive-deny-paths 'grok))
+(check-pred! "grok sandbox denies ~/.claude"
+  grok-deny
+  (lambda (ps)
+    (let lp ([p ps])
+      (cond [(null? p) #f]
+            [(str-contains? (car p) ".claude") #t]
+            [else (lp (cdr p))]))))
+(check! "grok sandbox does NOT deny its own ~/.grok"
+        (let lp ([p grok-deny])
+          (cond [(null? p) #f]
+                [(str-contains? (car p) "/.grok") #t]
+                [else (lp (cdr p))]))
+        #f)
+
+;; Sandbox: when claude is the chosen CLI, ~/.grok IS denied (cross-CLI
+;; auth isolation — a malicious or compromised claude CLI cannot lift the
+;; Grok bearer token).
+(define claude-deny (sensitive-deny-paths 'claude))
+(check-pred! "claude sandbox denies ~/.grok"
+  claude-deny
+  (lambda (ps)
+    (let lp ([p ps])
+      (cond [(null? p) #f]
+            [(str-contains? (car p) "/.grok") #t]
+            [else (lp (cdr p))]))))
+
+;; parse-grok-json: extracts text + sessionId; fallback session-id when missing.
+(let ([r (parse-grok-json
+           "{\"text\":\"Hello!\",\"sessionId\":\"sid-1\",\"stopReason\":\"end_turn\"}"
+           "fallback-sid")])
+  (check! "parse-grok-json text"        (ext-result-text r) "Hello!")
+  (check! "parse-grok-json sessionId"   (ext-result-session-id r) "sid-1")
+  (check! "parse-grok-json no error"    (ext-result-error? r) #f))
+(let ([r (parse-grok-json "{\"text\":\"Hi\"}" "fallback-sid")])
+  (check! "parse-grok-json fallback sid" (ext-result-session-id r) "fallback-sid"))
+(let ([r (parse-grok-json "not json"     "sid-x")])
+  (check! "parse-grok-json non-json → raw" (ext-result-text r) "not json")
+  (check! "parse-grok-json non-json sid"   (ext-result-session-id r) "sid-x"))
+
 ;; ── Results ───────────────────────────────────────────────────────
 
 (printf "~n~a passed, ~a failed~n" pass-count fail-count)