feat(grok): add Grok Build (xAI) as a seventh CLI run mode

SessionMode gains 'grok', a first-class backend alongside Claude Code,
shell, OpenCode, Codex, Gemini, Antigravity and Pi: its own PTY, tmux
session, charcoal tab identity ('gk' badge), welcome button, run-mode
entry, cron agentType, Docker and remote-SSH command defaults, and
clone-repo Brain option. Flag surface verified live against grok 1.0.5.

Grok mixes two existing shapes and the wiring follows from that:

- Codex-shaped on permissions: the bypass switch is GrokConfig.alwaysApprove
  (--always-approve, grok's bypassPermissions mode; config-level deny rules
  still apply on top). The Run button sends it true, like runAntigravity(),
  and clampExternalCliBypassForOwner() puts grok in the only-if-sent branch:
  a bare grok spawn is grok's own ask-mode default, which is already safe,
  so only a sent config needs the flag forced off. Cron needs nothing for
  the same reason.
- OpenCode-shaped on rendering: grok is a fullscreen alternate-screen TUI
  with mouse support, so it stays OUT of isAltScreenStripMode() and lands
  on the narrow tmux-attach strip and the 'buffer' local-echo fallthrough
  (unmeasured against an authenticated composer; documented fallback is the
  'off' branch).
- Pi-shaped on resolution: 'grok' has npm squatters (@vibe-kit/grok-cli
  also installs a grok bin), so grok-cli-resolver.ts version-probes every
  candidate (grok --version, killSignal SIGKILL, VITEST-gated) and
  GET /api/grok/status surfaces path AND version; GROK_VERSION_REGEX is
  shared with the dependency registry so doctor and run mode cannot drift.

Env allowlist gains GROK_* plus the XAI_* vendor namespace (XAI_API_KEY is
grok's documented headless auth var), the same narrow-vendor reasoning as
GOOGLE_* for gemini. Resume is id-regexed on purpose: grok's own --resume
also matches session titles, which are arbitrary user strings that must
never reach the bash -c spawn line.

Docker: grok is not on npm, so the agent image installs it in its own step
(xAI's installer has no --dir override; the binary is copied to
/usr/local/bin and root's ~/.grok dropped in the same layer), and
credentials are seeded per-file (auth.json, config.toml, pager.toml; the
dir also holds sessions/, memory/ and the ~160MB binary). Remote SSH routes
through the login-shell wrapper like the other agent CLIs.

Verified end to end on an isolated CODEMAN_INSTANCE with grok 1.0.5
installed: /api/grok/status resolves and reports the probed version,
quick-start spawns a pane whose command line ends in 'grok
--always-approve', the real TUI renders (OAuth device screen on an
unauthenticated box), and grokConfig round-trips through state.json.
Docs: docs/grok-integration.md (user guide) + docs/grok-integration-plan.md
(decisions, verification record, follow-ups).

Tests: test/grok-mode.test.ts, test/grok-cli-resolver.test.ts, plus
extended clamp/system-routes/render-index-html/run-mode-ui/mobile-overview/
local-echo-gating coverage. npm test (the CI gate) green: 5910 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-23 08:39:03 +02:00
parent 88bb98de43
commit 3f8c8e99d1
55 changed files with 1474 additions and 119 deletions
+65 -2
View File
@@ -52,6 +52,7 @@ import {
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -92,6 +93,8 @@ import {
getAntigravityNotFoundMessage,
resolvePiDir,
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -802,6 +805,47 @@ function buildPiCommand(config?: PiConfig): string {
return parts.join(' ');
}
/**
* Build the Grok Build CLI (xAI `grok`) command with appropriate flags.
*
* The bypass switch is `--always-approve` ("auto-approve all tool executions",
* grok's `bypassPermissions` permission mode; config-level deny rules still
* apply on top). Absent config spawns bare `grok`, i.e. grok's own default
* ask-mode, which is why the multi-user clamp only needs the only-if-sent
* branch for grok. Flag surface verified against grok 1.0.5.
*
* `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through
* socket-scoped `tmux setenv` (envOverrides), never the spawn command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildGrokCommand(config?: GrokConfig): string {
const parts = ['grok'];
if (config?.alwaysApprove) {
parts.push('--always-approve');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and -c conflict; a valid explicit session id wins. Ids only:
// grok's --resume also accepts session TITLES, which are arbitrary user
// strings, so the id regex doubles as the no-titles rule here.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -845,6 +889,7 @@ export function buildSpawnCommand(options: {
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -894,6 +939,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'pi') {
return buildPiCommand(options.piConfig);
}
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1109,6 +1157,8 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --conversation ${resumeId}`;
case 'pi':
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1699,10 +1749,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []),
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
@@ -1797,6 +1849,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolvePiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'grok') {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -1846,6 +1902,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1906,6 +1963,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1920,6 +1980,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2144,6 +2205,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2173,6 +2235,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
resumeSessionId,
effort,
sessionName: name,