Skip to content

Commit b323b98

Browse files
konardskulidropek
andauthored
feat(auth): add Grok authentication support (#305)
* Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #304 * feat(auth): add grok authentication support * fix(auth): include grok in auto agent selection * fix(auth): tolerate missing grok api key in entrypoint * fix(auth): address grok review feedback * test(api): allow slow docker access failure path * fix(auth): detect nested grok oauth credentials * fix(auth): reject empty grok project keys * test(e2e): avoid random port collisions * fix(auth): install official grok cli * fix(auth): harden grok cli support * fix(auth): address grok review followups * fix(auth): address grok bootstrap review * fix(auth): derive grok auth file owner --------- Co-authored-by: skulidropek <66840575+skulidropek@users.noreply.github.com>
1 parent 373a7e8 commit b323b98

119 files changed

Lines changed: 4381 additions & 551 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ bun run docker-git --help
2525
docker-git auth github login --web
2626
docker-git auth codex login --web
2727
docker-git auth claude login --web
28+
docker-git auth grok login --web
2829
```
2930

31+
Grok support uses the official xAI CLI installer from `https://x.ai/cli/install.sh`
32+
and the CLI device-code login flow. API-key auth can also be stored under the
33+
selected Grok account label via `GROK_DEPLOYMENT_KEY`, `GROK_API_KEY`, or
34+
`XAI_API_KEY`.
35+
3036
## CLI пример
3137

3238
Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.
@@ -44,8 +50,8 @@ docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force
4450
docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --auto
4551
```
4652

47-
- `--auto` сам выбирает Claude или Codex по доступной авторизации. Если доступны оба, выбор случайный.
48-
- `--auto=claude` или `--auto=codex` принудительно выбирает агента.
53+
- `--auto` сам выбирает Claude, Codex, Gemini или Grok по доступной авторизации. Если доступно несколько, выбор случайный.
54+
- `--auto=claude`, `--auto=codex`, `--auto=gemini` или `--auto=grok` принудительно выбирает агента.
4955
- В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается.
5056

5157
Применение конфигурации:

packages/api/src/api/contracts.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type ProjectStatus = "running" | "stopped" | "unknown"
22

3-
export type AgentProvider = "codex" | "opencode" | "claude" | "custom"
3+
export type AgentProvider = "codex" | "opencode" | "claude" | "grok" | "custom"
44

55
export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exited" | "failed"
66

@@ -183,19 +183,23 @@ export type AuthMenuFlow =
183183
| "ClaudeLogout"
184184
| "GeminiApiKey"
185185
| "GeminiLogout"
186+
| "GrokApiKey"
187+
| "GrokLogout"
186188

187-
export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth"
189+
export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth"
188190

189191
export type AuthSnapshot = {
190192
readonly globalEnvPath: string
191193
readonly claudeAuthPath: string
192194
readonly geminiAuthPath: string
195+
readonly grokAuthPath: string
193196
readonly totalEntries: number
194197
readonly githubTokenEntries: number
195198
readonly gitTokenEntries: number
196199
readonly gitUserEntries: number
197200
readonly claudeAuthEntries: number
198201
readonly geminiAuthEntries: number
202+
readonly grokAuthEntries: number
199203
}
200204

201205
export type AuthMenuRequest = {
@@ -249,6 +253,8 @@ export type ProjectAuthFlow =
249253
| "ProjectClaudeDisconnect"
250254
| "ProjectGeminiConnect"
251255
| "ProjectGeminiDisconnect"
256+
| "ProjectGrokConnect"
257+
| "ProjectGrokDisconnect"
252258

253259
export type ProjectAuthSnapshot = {
254260
readonly projectDir: string
@@ -257,22 +263,25 @@ export type ProjectAuthSnapshot = {
257263
readonly envProjectPath: string
258264
readonly claudeAuthPath: string
259265
readonly geminiAuthPath: string
266+
readonly grokAuthPath: string
260267
readonly githubTokenEntries: number
261268
readonly gitTokenEntries: number
262269
readonly claudeAuthEntries: number
263270
readonly geminiAuthEntries: number
271+
readonly grokAuthEntries: number
264272
readonly activeGithubLabel: string | null
265273
readonly activeGitLabel: string | null
266274
readonly activeClaudeLabel: string | null
267275
readonly activeGeminiLabel: string | null
276+
readonly activeGrokLabel: string | null
268277
}
269278

270279
export type ProjectAuthRequest = {
271280
readonly flow: ProjectAuthFlow
272281
readonly label?: string | null | undefined
273282
}
274283

275-
export type ProjectPromptKind = "claude" | "codex" | "gemini"
284+
export type ProjectPromptKind = "claude" | "codex" | "gemini" | "grok"
276285

277286
export type ProjectPromptFile = {
278287
readonly kind: ProjectPromptKind
@@ -302,6 +311,7 @@ export type ProjectSkillScope =
302311
| "claude/skills"
303312
| "codex/skills"
304313
| "gemini/skills"
314+
| "grok/skills"
305315

306316
export type ProjectSkillFile = {
307317
readonly id: string
@@ -400,6 +410,8 @@ export type CreateProjectRequest = {
400410
readonly skipGithubAuth?: boolean | undefined
401411
readonly codexTokenLabel?: string | undefined
402412
readonly claudeTokenLabel?: string | undefined
413+
readonly geminiTokenLabel?: string | undefined
414+
readonly grokTokenLabel?: string | undefined
403415
readonly agentAutoMode?: string | undefined
404416
readonly up?: boolean | undefined
405417
readonly openSsh?: boolean | undefined

packages/api/src/api/schema.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export const CreateProjectRequestSchema = Schema.Struct({
3434
skipGithubAuth: OptionalBoolean,
3535
codexTokenLabel: OptionalString,
3636
claudeTokenLabel: OptionalString,
37+
geminiTokenLabel: OptionalString,
38+
grokTokenLabel: OptionalString,
3739
agentAutoMode: OptionalString,
3840
up: OptionalBoolean,
3941
openSsh: OptionalBoolean,
@@ -60,10 +62,12 @@ export const AuthMenuFlowSchema = Schema.Literal(
6062
"GitRemove",
6163
"ClaudeLogout",
6264
"GeminiApiKey",
63-
"GeminiLogout"
65+
"GeminiLogout",
66+
"GrokApiKey",
67+
"GrokLogout"
6468
)
6569

66-
export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth")
70+
export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth", "GrokOauth")
6771

6872
export const AuthMenuRequestSchema = Schema.Struct({
6973
flow: AuthMenuFlowSchema,
@@ -107,15 +111,17 @@ export const ProjectAuthFlowSchema = Schema.Literal(
107111
"ProjectClaudeConnect",
108112
"ProjectClaudeDisconnect",
109113
"ProjectGeminiConnect",
110-
"ProjectGeminiDisconnect"
114+
"ProjectGeminiDisconnect",
115+
"ProjectGrokConnect",
116+
"ProjectGrokDisconnect"
111117
)
112118

113119
export const ProjectAuthRequestSchema = Schema.Struct({
114120
flow: ProjectAuthFlowSchema,
115121
label: OptionalNullableString
116122
})
117123

118-
export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini")
124+
export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini", "grok")
119125

120126
export const ProjectPromptUpdateRequestSchema = Schema.Struct({
121127
content: Schema.String
@@ -127,7 +133,8 @@ export const ProjectSkillScopeSchema = Schema.Literal(
127133
"agents/.skills",
128134
"claude/skills",
129135
"codex/skills",
130-
"gemini/skills"
136+
"gemini/skills",
137+
"grok/skills"
131138
)
132139

133140
export const ProjectSkillUpdateRequestSchema = Schema.Struct({
@@ -246,7 +253,7 @@ export const ProjectDatabaseForwardSchema = Schema.Struct({
246253
targetPort: Schema.Number
247254
})
248255

249-
export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom")
256+
export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "grok", "custom")
250257

251258
export const AgentEnvVarSchema = Schema.Struct({
252259
key: Schema.String,

packages/api/src/auth-terminal-runner.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { NodeContext, NodeRuntime } from "@effect/platform-node"
2-
import { authClaudeLogin, authGeminiLoginOauth } from "@effect-template/lib"
2+
import { authClaudeLogin, authGeminiLoginOauth, authGrokLoginOauth } from "@effect-template/lib"
33
import { Effect, Match } from "effect"
44

5-
type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth"
5+
type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth"
66

7-
const parseFlow = (value: string | undefined): AuthTerminalRunnerFlow =>
8-
value === "ClaudeOauth" || value === "GeminiOauth" ? value : "ClaudeOauth"
7+
const parseFlow = (value: string | undefined): AuthTerminalRunnerFlow => {
8+
if (value === "ClaudeOauth" || value === "GeminiOauth" || value === "GrokOauth") {
9+
return value
10+
}
11+
process.stderr.write(`Unsupported auth terminal flow: ${value ?? "<empty>"}\n`)
12+
process.exit(2)
13+
}
914

1015
const parseLabel = (value: string | undefined): string | null => {
1116
const trimmed = value?.trim() ?? ""
@@ -29,6 +34,13 @@ const program = Match.value(flow).pipe(
2934
geminiAuthPath: ".docker-git/.orch/auth/gemini",
3035
isWeb: false
3136
})),
37+
Match.when("GrokOauth", () =>
38+
authGrokLoginOauth({
39+
_tag: "AuthGrokLogin",
40+
label,
41+
grokAuthPath: ".docker-git/.orch/auth/grok",
42+
isWeb: false
43+
})),
3244
Match.exhaustive
3345
)
3446

packages/api/src/http.ts

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Chunk, Duration, Effect, Ref } from "effect"
1+
import { Chunk, Duration, Effect, Match, Ref } from "effect"
22
import * as Stream from "effect/Stream"
33
import type { PlatformError } from "@effect/platform/Error"
44
import type * as HttpBody from "@effect/platform/HttpBody"
@@ -182,7 +182,7 @@ const ProjectDatabaseProfileParamsSchema = Schema.Struct({
182182

183183
const ProjectPromptParamsSchema = Schema.Struct({
184184
projectId: Schema.String,
185-
kind: Schema.Literal("claude", "codex", "gemini")
185+
kind: Schema.Literal("claude", "codex", "gemini", "grok")
186186
})
187187

188188
const ProjectSkillParamsSchema = Schema.Struct({
@@ -427,55 +427,43 @@ const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(Pro
427427
const readActiveProjectTerminalSessionRequest = () =>
428428
HttpServerRequest.schemaBodyJson(ActiveProjectTerminalSessionRequestSchema)
429429

430-
const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => {
431-
switch (scopeId) {
432-
case "skills":
433-
return "skills"
434-
case "agents-skills":
435-
return "agents/skills"
436-
case "agents-dot-skills":
437-
return "agents/.skills"
438-
case "claude-skills":
439-
return "claude/skills"
440-
case "codex-skills":
441-
return "codex/skills"
442-
case "gemini-skills":
443-
return "gemini/skills"
444-
default:
445-
return null
446-
}
447-
}
430+
const projectSkillScope = (scope: ProjectSkillScope): ProjectSkillScope => scope
431+
432+
const skillScopeFromId = (scopeId: string): ProjectSkillScope | null =>
433+
Match.value(scopeId).pipe(
434+
Match.when("skills", () => projectSkillScope("skills")),
435+
Match.when("agents-skills", () => projectSkillScope("agents/skills")),
436+
Match.when("agents-dot-skills", () => projectSkillScope("agents/.skills")),
437+
Match.when("claude-skills", () => projectSkillScope("claude/skills")),
438+
Match.when("codex-skills", () => projectSkillScope("codex/skills")),
439+
Match.when("gemini-skills", () => projectSkillScope("gemini/skills")),
440+
Match.when("grok-skills", () => projectSkillScope("grok/skills")),
441+
Match.orElse(() => null)
442+
)
448443

449-
export const skillScopeToId = (scope: ProjectSkillScope): string => {
450-
switch (scope) {
451-
case "skills":
452-
return "skills"
453-
case "agents/skills":
454-
return "agents-skills"
455-
case "agents/.skills":
456-
return "agents-dot-skills"
457-
case "claude/skills":
458-
return "claude-skills"
459-
case "codex/skills":
460-
return "codex-skills"
461-
case "gemini/skills":
462-
return "gemini-skills"
463-
}
464-
}
444+
export const skillScopeToId = (scope: ProjectSkillScope): string =>
445+
Match.value(scope).pipe(
446+
Match.when("skills", () => "skills"),
447+
Match.when("agents/skills", () => "agents-skills"),
448+
Match.when("agents/.skills", () => "agents-dot-skills"),
449+
Match.when("claude/skills", () => "claude-skills"),
450+
Match.when("codex/skills", () => "codex-skills"),
451+
Match.when("gemini/skills", () => "gemini-skills"),
452+
Match.when("grok/skills", () => "grok-skills"),
453+
Match.exhaustive
454+
)
465455

466-
const skillScopeFromBody = (scope: string): ProjectSkillScope | null => {
467-
switch (scope) {
468-
case "skills":
469-
case "agents/skills":
470-
case "agents/.skills":
471-
case "claude/skills":
472-
case "codex/skills":
473-
case "gemini/skills":
474-
return scope as ProjectSkillScope
475-
default:
476-
return null
477-
}
478-
}
456+
const skillScopeFromBody = (scope: string): ProjectSkillScope | null =>
457+
Match.value(scope).pipe(
458+
Match.when("skills", () => projectSkillScope("skills")),
459+
Match.when("agents/skills", () => projectSkillScope("agents/skills")),
460+
Match.when("agents/.skills", () => projectSkillScope("agents/.skills")),
461+
Match.when("claude/skills", () => projectSkillScope("claude/skills")),
462+
Match.when("codex/skills", () => projectSkillScope("codex/skills")),
463+
Match.when("gemini/skills", () => projectSkillScope("gemini/skills")),
464+
Match.when("grok/skills", () => projectSkillScope("grok/skills")),
465+
Match.orElse(() => null)
466+
)
479467
const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema)
480468
const readProjectDatabaseProfileRequest = () => HttpServerRequest.schemaBodyJson(ProjectDatabaseProfileRequestSchema)
481469
const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema)

packages/api/src/services/agents.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string =>
6969
if (provider === "claude") {
7070
return "MCP_PLAYWRIGHT_ISOLATED=1 claude"
7171
}
72+
if (provider === "grok") {
73+
return "MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox"
74+
}
7275
return ""
7376
}
7477

0 commit comments

Comments
 (0)