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
+106 -1
View File
@@ -176,6 +176,7 @@ describe('Run launch synchronization', () => {
'runGemini',
'runAntigravity',
'runPi',
'runGrok',
])
);
@@ -365,12 +366,13 @@ describe('Codex quick start settings', () => {
'welcomeAntigravityBtn',
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -402,6 +404,7 @@ describe('Codex quick start settings', () => {
gemini: false,
antigravity: false,
pi: false,
grok: false,
cloudflared: false,
};
@@ -425,6 +428,13 @@ describe('Codex quick start settings', () => {
const withPi = loadUi({ ...ALL_OFF, pi: true });
withPi.app.applyWelcomeCliVisibility();
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
// Grok is gated on `grok` like the rest; the resolver additionally
// version-probes the binary, so a stray `grok` on PATH reports unavailable.
const withGrok = loadUi({ ...ALL_OFF, grok: true });
withGrok.app.applyWelcomeCliVisibility();
expect(withGrok.welcomeBtns.welcomeGrokBtn.style.display).toBe('flex');
expect(withGrok.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
@@ -457,6 +467,7 @@ describe('Codex quick start settings', () => {
);
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
@@ -993,3 +1004,97 @@ describe('Pi quick start', () => {
expect(errors[0]).toContain('@earendil-works/pi-coding-agent');
});
});
describe('Grok quick start', () => {
// Same envelope-unwrap regression guard as the blocks above, for runGrok(),
// plus the rule that makes grok the OPPOSITE of pi: the Run button DOES send
// `grokConfig: { alwaysApprove: true }` (grok's bypassPermissions mode), the
// same product decision as runAntigravity's dangerouslySkipPermissions and
// claude's --dangerously-skip-permissions. The multi-user clamp strips it
// server-side for non-granted owners.
it('drives runGrok() through the {success,data} envelope and sends alwaysApprove', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'grok-case' },
};
const requests: Array<{ url: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string, init?: { body?: string }) => {
requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
if (url === '/api/grok/status')
return {
json: async () => ({
success: true,
data: { available: true, path: '/home/user/.grok/bin', version: '1.0.5' },
}),
};
if (url === '/api/quick-start')
return { json: async () => ({ success: true, data: { sessionId: 'sess-gk' } }) };
if (url === '/api/sessions/sess-gk')
return { json: async () => ({ success: true, data: { id: 'sess-gk', name: 'w1-grok-case' } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
app.loadAppSettingsFromStorage = () => ({});
app.getCaseSettings = () => ({});
app.buildEnvOverrides = () => ({});
app.sessions = new Map();
app._onSessionCreated = (session: any) => app.sessions.set(session.id, session);
app._renderSessionTabsImmediate = vi.fn();
const selected: string[] = [];
app.selectSession = async (id: string) => {
selected.push(id);
};
await app.runGrok();
const body = requests.find((req) => req.url === '/api/quick-start')?.body;
expect(body).toMatchObject({
caseName: 'grok-case',
mode: 'grok',
grokConfig: { alwaysApprove: true },
});
expect(selected).toEqual(['sess-gk']);
});
it('reports the install hint when the CLI is missing and starts nothing', async () => {
const elements: Record<string, any> = { quickStartCase: { value: 'grok-case' } };
const requests: string[] = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => elements[id] ?? null },
fetch: async (url: string) => {
requests.push(url);
if (url === '/api/grok/status')
return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) };
throw new Error(`unexpected fetch: ${url}`);
},
console,
});
const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
const app = new (CodemanApp as any)();
app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
const errors: string[] = [];
app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg);
await app.runGrok();
expect(requests).toEqual(['/api/grok/status']);
expect(errors[0]).toContain('https://x.ai/cli/install.sh');
});
});