external-generated-code + tui: /ask-grok + /grok tab (add-grok phase 6)
ober
43105b1c6f9b526fa4e3eddef3bdd8dbbcecfab7
--- 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)))))) --- 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. --- 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)