diff --git a/README.md b/README.md index 84c9ca20..17a6f001 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,14 @@ bun run docker-git --help docker-git auth github login --web docker-git auth codex login --web docker-git auth claude login --web +docker-git auth grok login --web ``` +Grok support uses the official xAI CLI installer from `https://x.ai/cli/install.sh` +and the CLI device-code login flow. API-key auth can also be stored under the +selected Grok account label via `GROK_DEPLOYMENT_KEY`, `GROK_API_KEY`, or +`XAI_API_KEY`. + ## CLI пример Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR. @@ -44,8 +50,8 @@ docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force docker-git clone https://github.com/ProverCoderAI/docker-git/issues/122 --force --auto ``` -- `--auto` сам выбирает Claude или Codex по доступной авторизации. Если доступны оба, выбор случайный. -- `--auto=claude` или `--auto=codex` принудительно выбирает агента. +- `--auto` сам выбирает Claude, Codex, Gemini или Grok по доступной авторизации. Если доступно несколько, выбор случайный. +- `--auto=claude`, `--auto=codex`, `--auto=gemini` или `--auto=grok` принудительно выбирает агента. - В auto-режиме агент сам выполняет задачу, создаёт PR и после завершения контейнер очищается. Применение конфигурации: diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 874d0759..6117d0f4 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -1,6 +1,6 @@ export type ProjectStatus = "running" | "stopped" | "unknown" -export type AgentProvider = "codex" | "opencode" | "claude" | "custom" +export type AgentProvider = "codex" | "opencode" | "claude" | "grok" | "custom" export type AgentStatus = "starting" | "running" | "stopping" | "stopped" | "exited" | "failed" @@ -183,19 +183,23 @@ export type AuthMenuFlow = | "ClaudeLogout" | "GeminiApiKey" | "GeminiLogout" + | "GrokApiKey" + | "GrokLogout" -export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" +export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" export type AuthSnapshot = { readonly globalEnvPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly totalEntries: number readonly githubTokenEntries: number readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number } export type AuthMenuRequest = { @@ -249,6 +253,8 @@ export type ProjectAuthFlow = | "ProjectClaudeDisconnect" | "ProjectGeminiConnect" | "ProjectGeminiDisconnect" + | "ProjectGrokConnect" + | "ProjectGrokDisconnect" export type ProjectAuthSnapshot = { readonly projectDir: string @@ -257,14 +263,17 @@ export type ProjectAuthSnapshot = { readonly envProjectPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly githubTokenEntries: number readonly gitTokenEntries: number readonly claudeAuthEntries: number readonly geminiAuthEntries: number + readonly grokAuthEntries: number readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null readonly activeGeminiLabel: string | null + readonly activeGrokLabel: string | null } export type ProjectAuthRequest = { @@ -272,7 +281,7 @@ export type ProjectAuthRequest = { readonly label?: string | null | undefined } -export type ProjectPromptKind = "claude" | "codex" | "gemini" +export type ProjectPromptKind = "claude" | "codex" | "gemini" | "grok" export type ProjectPromptFile = { readonly kind: ProjectPromptKind @@ -302,6 +311,7 @@ export type ProjectSkillScope = | "claude/skills" | "codex/skills" | "gemini/skills" + | "grok/skills" export type ProjectSkillFile = { readonly id: string @@ -400,6 +410,8 @@ export type CreateProjectRequest = { readonly skipGithubAuth?: boolean | undefined readonly codexTokenLabel?: string | undefined readonly claudeTokenLabel?: string | undefined + readonly geminiTokenLabel?: string | undefined + readonly grokTokenLabel?: string | undefined readonly agentAutoMode?: string | undefined readonly up?: boolean | undefined readonly openSsh?: boolean | undefined diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index ac73bb6f..31271a85 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -34,6 +34,8 @@ export const CreateProjectRequestSchema = Schema.Struct({ skipGithubAuth: OptionalBoolean, codexTokenLabel: OptionalString, claudeTokenLabel: OptionalString, + geminiTokenLabel: OptionalString, + grokTokenLabel: OptionalString, agentAutoMode: OptionalString, up: OptionalBoolean, openSsh: OptionalBoolean, @@ -60,10 +62,12 @@ export const AuthMenuFlowSchema = Schema.Literal( "GitRemove", "ClaudeLogout", "GeminiApiKey", - "GeminiLogout" + "GeminiLogout", + "GrokApiKey", + "GrokLogout" ) -export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth") +export const AuthTerminalFlowSchema = Schema.Literal("ClaudeOauth", "GeminiOauth", "GrokOauth") export const AuthMenuRequestSchema = Schema.Struct({ flow: AuthMenuFlowSchema, @@ -107,7 +111,9 @@ export const ProjectAuthFlowSchema = Schema.Literal( "ProjectClaudeConnect", "ProjectClaudeDisconnect", "ProjectGeminiConnect", - "ProjectGeminiDisconnect" + "ProjectGeminiDisconnect", + "ProjectGrokConnect", + "ProjectGrokDisconnect" ) export const ProjectAuthRequestSchema = Schema.Struct({ @@ -115,7 +121,7 @@ export const ProjectAuthRequestSchema = Schema.Struct({ label: OptionalNullableString }) -export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini") +export const ProjectPromptKindSchema = Schema.Literal("claude", "codex", "gemini", "grok") export const ProjectPromptUpdateRequestSchema = Schema.Struct({ content: Schema.String @@ -127,7 +133,8 @@ export const ProjectSkillScopeSchema = Schema.Literal( "agents/.skills", "claude/skills", "codex/skills", - "gemini/skills" + "gemini/skills", + "grok/skills" ) export const ProjectSkillUpdateRequestSchema = Schema.Struct({ @@ -246,7 +253,7 @@ export const ProjectDatabaseForwardSchema = Schema.Struct({ targetPort: Schema.Number }) -export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "custom") +export const AgentProviderSchema = Schema.Literal("codex", "opencode", "claude", "grok", "custom") export const AgentEnvVarSchema = Schema.Struct({ key: Schema.String, diff --git a/packages/api/src/auth-terminal-runner.ts b/packages/api/src/auth-terminal-runner.ts index 2b624d6d..4bc5007d 100644 --- a/packages/api/src/auth-terminal-runner.ts +++ b/packages/api/src/auth-terminal-runner.ts @@ -1,11 +1,16 @@ import { NodeContext, NodeRuntime } from "@effect/platform-node" -import { authClaudeLogin, authGeminiLoginOauth } from "@effect-template/lib" +import { authClaudeLogin, authGeminiLoginOauth, authGrokLoginOauth } from "@effect-template/lib" import { Effect, Match } from "effect" -type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth" +type AuthTerminalRunnerFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" -const parseFlow = (value: string | undefined): AuthTerminalRunnerFlow => - value === "ClaudeOauth" || value === "GeminiOauth" ? value : "ClaudeOauth" +const parseFlow = (value: string | undefined): AuthTerminalRunnerFlow => { + if (value === "ClaudeOauth" || value === "GeminiOauth" || value === "GrokOauth") { + return value + } + process.stderr.write(`Unsupported auth terminal flow: ${value ?? ""}\n`) + process.exit(2) +} const parseLabel = (value: string | undefined): string | null => { const trimmed = value?.trim() ?? "" @@ -29,6 +34,13 @@ const program = Match.value(flow).pipe( geminiAuthPath: ".docker-git/.orch/auth/gemini", isWeb: false })), + Match.when("GrokOauth", () => + authGrokLoginOauth({ + _tag: "AuthGrokLogin", + label, + grokAuthPath: ".docker-git/.orch/auth/grok", + isWeb: false + })), Match.exhaustive ) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 266571fc..efdf486f 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -1,4 +1,4 @@ -import { Chunk, Duration, Effect, Ref } from "effect" +import { Chunk, Duration, Effect, Match, Ref } from "effect" import * as Stream from "effect/Stream" import type { PlatformError } from "@effect/platform/Error" import type * as HttpBody from "@effect/platform/HttpBody" @@ -182,7 +182,7 @@ const ProjectDatabaseProfileParamsSchema = Schema.Struct({ const ProjectPromptParamsSchema = Schema.Struct({ projectId: Schema.String, - kind: Schema.Literal("claude", "codex", "gemini") + kind: Schema.Literal("claude", "codex", "gemini", "grok") }) const ProjectSkillParamsSchema = Schema.Struct({ @@ -427,55 +427,43 @@ const readProjectSkillUpdateRequest = () => HttpServerRequest.schemaBodyJson(Pro const readActiveProjectTerminalSessionRequest = () => HttpServerRequest.schemaBodyJson(ActiveProjectTerminalSessionRequestSchema) -const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => { - switch (scopeId) { - case "skills": - return "skills" - case "agents-skills": - return "agents/skills" - case "agents-dot-skills": - return "agents/.skills" - case "claude-skills": - return "claude/skills" - case "codex-skills": - return "codex/skills" - case "gemini-skills": - return "gemini/skills" - default: - return null - } -} +const projectSkillScope = (scope: ProjectSkillScope): ProjectSkillScope => scope + +const skillScopeFromId = (scopeId: string): ProjectSkillScope | null => + Match.value(scopeId).pipe( + Match.when("skills", () => projectSkillScope("skills")), + Match.when("agents-skills", () => projectSkillScope("agents/skills")), + Match.when("agents-dot-skills", () => projectSkillScope("agents/.skills")), + Match.when("claude-skills", () => projectSkillScope("claude/skills")), + Match.when("codex-skills", () => projectSkillScope("codex/skills")), + Match.when("gemini-skills", () => projectSkillScope("gemini/skills")), + Match.when("grok-skills", () => projectSkillScope("grok/skills")), + Match.orElse(() => null) + ) -export const skillScopeToId = (scope: ProjectSkillScope): string => { - switch (scope) { - case "skills": - return "skills" - case "agents/skills": - return "agents-skills" - case "agents/.skills": - return "agents-dot-skills" - case "claude/skills": - return "claude-skills" - case "codex/skills": - return "codex-skills" - case "gemini/skills": - return "gemini-skills" - } -} +export const skillScopeToId = (scope: ProjectSkillScope): string => + Match.value(scope).pipe( + Match.when("skills", () => "skills"), + Match.when("agents/skills", () => "agents-skills"), + Match.when("agents/.skills", () => "agents-dot-skills"), + Match.when("claude/skills", () => "claude-skills"), + Match.when("codex/skills", () => "codex-skills"), + Match.when("gemini/skills", () => "gemini-skills"), + Match.when("grok/skills", () => "grok-skills"), + Match.exhaustive + ) -const skillScopeFromBody = (scope: string): ProjectSkillScope | null => { - switch (scope) { - case "skills": - case "agents/skills": - case "agents/.skills": - case "claude/skills": - case "codex/skills": - case "gemini/skills": - return scope as ProjectSkillScope - default: - return null - } -} +const skillScopeFromBody = (scope: string): ProjectSkillScope | null => + Match.value(scope).pipe( + Match.when("skills", () => projectSkillScope("skills")), + Match.when("agents/skills", () => projectSkillScope("agents/skills")), + Match.when("agents/.skills", () => projectSkillScope("agents/.skills")), + Match.when("claude/skills", () => projectSkillScope("claude/skills")), + Match.when("codex/skills", () => projectSkillScope("codex/skills")), + Match.when("gemini/skills", () => projectSkillScope("gemini/skills")), + Match.when("grok/skills", () => projectSkillScope("grok/skills")), + Match.orElse(() => null) + ) const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema) const readProjectDatabaseProfileRequest = () => HttpServerRequest.schemaBodyJson(ProjectDatabaseProfileRequestSchema) const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema) diff --git a/packages/api/src/services/agents.ts b/packages/api/src/services/agents.ts index 6c60670c..0b3bf3e9 100644 --- a/packages/api/src/services/agents.ts +++ b/packages/api/src/services/agents.ts @@ -69,6 +69,9 @@ const pickDefaultCommand = (provider: CreateAgentRequest["provider"]): string => if (provider === "claude") { return "MCP_PLAYWRIGHT_ISOLATED=1 claude" } + if (provider === "grok") { + return "MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox" + } return "" } diff --git a/packages/api/src/services/auth-menu.ts b/packages/api/src/services/auth-menu.ts index ed948d95..4a86ce3b 100644 --- a/packages/api/src/services/auth-menu.ts +++ b/packages/api/src/services/auth-menu.ts @@ -2,7 +2,7 @@ import * as FileSystem from "@effect/platform/FileSystem" import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as Path from "@effect/platform/Path" -import { authClaudeLogout, authGeminiLogin, authGeminiLogout } from "@effect-template/lib/usecases/auth" +import { authClaudeLogout, authGeminiLogin, authGeminiLogout, authGrokLogin, authGrokLogout } from "@effect-template/lib/usecases/auth" import { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file" import { renderError, type AppError } from "@effect-template/lib/usecases/errors" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" @@ -16,6 +16,7 @@ type MenuAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.Comma const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude` const geminiAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/gemini` +const grokAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/grok` const globalEnvPath = `${defaultProjectsRoot(process.cwd())}/.orch/env/global.env` const normalizeLabel = (value: string): string => { @@ -102,18 +103,21 @@ export const readAuthMenuSnapshot = (): Effect.Effect Effect.all({ claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot) + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), + grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) }).pipe( - Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({ + Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ globalEnvPath, claudeAuthPath: claudeAuthRoot, geminiAuthPath: geminiAuthRoot, + grokAuthPath: grokAuthRoot, totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length, githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"), gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"), gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"), claudeAuthEntries, - geminiAuthEntries + geminiAuthEntries, + grokAuthEntries })) ) ) @@ -135,7 +139,11 @@ const syncMessage = (request: AuthMenuRequest): string => ? `chore(state): auth claude logout ${canonicalLabel(request.label)}` : request.flow === "GeminiApiKey" ? `chore(state): auth gemini ${canonicalLabel(request.label)}` - : `chore(state): auth gemini logout ${canonicalLabel(request.label)}` + : request.flow === "GeminiLogout" + ? `chore(state): auth gemini logout ${canonicalLabel(request.label)}` + : request.flow === "GrokApiKey" + ? `chore(state): auth grok ${canonicalLabel(request.label)}` + : `chore(state): auth grok logout ${canonicalLabel(request.label)}` const writeEnvBackedAuthFlow = ( request: AuthMenuRequest @@ -213,15 +221,45 @@ export const runAuthMenuFlow = ( error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) ) ) - : pipe( - authGeminiLogout({ - _tag: "AuthGeminiLogout", - label: request.label ?? null, - geminiAuthPath: geminiAuthRoot - }), - Effect.mapError(mapMenuAuthError), - Effect.zipRight(readAuthMenuSnapshot()), - Effect.mapError((error) => - error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + : request.flow === "GeminiLogout" + ? pipe( + authGeminiLogout({ + _tag: "AuthGeminiLogout", + label: request.label ?? null, + geminiAuthPath: geminiAuthRoot + }), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) ) - ) + : request.flow === "GrokApiKey" + ? pipe( + authGrokLogin( + { + _tag: "AuthGrokLogin", + label: request.label ?? null, + grokAuthPath: grokAuthRoot, + isWeb: true + }, + request.apiKey ?? "" + ), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) + ) + : pipe( + authGrokLogout({ + _tag: "AuthGrokLogout", + label: request.label ?? null, + grokAuthPath: grokAuthRoot + }), + Effect.mapError(mapMenuAuthError), + Effect.zipRight(readAuthMenuSnapshot()), + Effect.mapError((error) => + error instanceof ApiBadRequestError ? error : new ApiBadRequestError({ message: String(error) }) + ) + ) diff --git a/packages/api/src/services/auth-terminal-sessions.ts b/packages/api/src/services/auth-terminal-sessions.ts index 0ffc5781..4580f9b7 100644 --- a/packages/api/src/services/auth-terminal-sessions.ts +++ b/packages/api/src/services/auth-terminal-sessions.ts @@ -1,6 +1,6 @@ import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" -import { Either, Effect } from "effect" +import { Either, Effect, Match } from "effect" import { randomUUID } from "node:crypto" import { fileURLToPath } from "node:url" import type { IncomingMessage, Server as HttpServer } from "node:http" @@ -69,9 +69,12 @@ const nowIso = (): string => new Date().toISOString() const resolveCommandLabel = (request: AuthTerminalSessionRequest): string => { const label = request.label?.trim() const suffix = label === undefined || label.length === 0 ? "" : ` [${label}]` - return request.flow === "ClaudeOauth" - ? `docker-git menu auth claude oauth${suffix}` - : `docker-git menu auth gemini oauth${suffix}` + return Match.value(request.flow).pipe( + Match.when("ClaudeOauth", () => `docker-git menu auth claude oauth${suffix}`), + Match.when("GeminiOauth", () => `docker-git menu auth gemini oauth${suffix}`), + Match.when("GrokOauth", () => `docker-git menu auth grok oauth${suffix}`), + Match.exhaustive + ) } const resolveRunnerArgs = (flow: AuthTerminalFlow, label: string | null | undefined): ReadonlyArray => { diff --git a/packages/api/src/services/container-tasks-core.ts b/packages/api/src/services/container-tasks-core.ts index 4aac9901..1b9850fd 100644 --- a/packages/api/src/services/container-tasks-core.ts +++ b/packages/api/src/services/container-tasks-core.ts @@ -17,7 +17,7 @@ export type ManagedAgentPid = { readonly pid: number } -const interactiveAgentPattern = /\b(codex|claude|opencode|gemini)\b/u +const interactiveAgentPattern = /\b(codex|claude|opencode|gemini|grok)\b/u const hasInteractiveTty = (process: RawContainerProcess): boolean => process.tty !== "?" && process.tty.trim().length > 0 diff --git a/packages/api/src/services/federation.ts b/packages/api/src/services/federation.ts index a43f7780..829cd77e 100644 --- a/packages/api/src/services/federation.ts +++ b/packages/api/src/services/federation.ts @@ -1695,7 +1695,7 @@ const resolveAgentProvider = ( subscription: FollowSubscription | undefined ): AgentProvider => { const raw = subscription?.agentProvider ?? process.env["DOCKER_GIT_EXCHANGE_AGENT_PROVIDER"] - return raw === "claude" || raw === "opencode" || raw === "custom" ? raw : "codex" + return raw === "claude" || raw === "opencode" || raw === "grok" || raw === "custom" ? raw : "codex" } const buildTaskPrompt = (issue: FederationIssueRecord): string => { @@ -1730,6 +1730,9 @@ const buildAgentCommand = ( if (provider === "opencode") { return `opencode run ${shellEscape(prompt)}` } + if (provider === "grok") { + return `MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox -p ${shellEscape(prompt)}` + } if (provider === "custom") { return `sh -lc ${shellEscape(`printf '%s\n' ${shellEscape(prompt)}`)}` } diff --git a/packages/api/src/services/project-auth.ts b/packages/api/src/services/project-auth.ts index f5a4e18f..5924d816 100644 --- a/packages/api/src/services/project-auth.ts +++ b/packages/api/src/services/project-auth.ts @@ -9,6 +9,7 @@ import { renderError, type AppError } from "@effect-template/lib/usecases/errors import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import { autoSyncState } from "@effect-template/lib/usecases/state-repo" import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers" +import { hasGrokAuthJsonCredentialText, hasGrokUserSettingsCredentialText } from "@effect-template/lib/usecases/auth-grok-credential-text" import type { ProjectAuthFlow, ProjectAuthRequest, ProjectAuthSnapshot, ProjectDetails } from "../api/contracts.js" import { ApiBadRequestError } from "../api/errors.js" @@ -17,6 +18,7 @@ type ProjectAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.Co const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude` const geminiAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/gemini` +const grokAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/grok` const globalEnvPath = `${defaultProjectsRoot(process.cwd())}/.orch/env/global.env` const githubTokenBaseKey = "GITHUB_TOKEN" @@ -26,7 +28,9 @@ const projectGithubLabelKey = "GITHUB_AUTH_LABEL" const projectGitLabelKey = "GIT_AUTH_LABEL" const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL" const projectGeminiLabelKey = "GEMINI_AUTH_LABEL" +const projectGrokLabelKey = "GROK_AUTH_LABEL" const defaultGitUser = "x-access-token" +const grokEnvApiKeyNames: ReadonlyArray = ["GROK_DEPLOYMENT_KEY", "GROK_API_KEY", "XAI_API_KEY"] const normalizeLabel = (value: string): string => { const trimmed = value.trim() @@ -170,7 +174,8 @@ const hasClaudeAccountCredentials = ( const hasApiKeyInEnvFile = ( fs: FileSystem.FileSystem, - envFilePath: string + envFilePath: string, + key: string ): Effect.Effect => Effect.gen(function*(_) { const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) @@ -179,12 +184,13 @@ const hasApiKeyInEnvFile = ( } const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) + const prefix = `${key}=` for (const line of envContent.split("\n")) { const trimmed = line.trim() - if (!trimmed.startsWith("GEMINI_API_KEY=")) { + if (!trimmed.startsWith(prefix)) { continue } - const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() if (value.length > 0) { return true } @@ -192,6 +198,20 @@ const hasApiKeyInEnvFile = ( return false }) +const hasNonEmptyApiKeyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, filePath)) + if (!hasFile) { + return false + } + + const apiKey = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return apiKey.trim().length > 0 + }) + const checkAnyFileExists = ( fs: FileSystem.FileSystem, basePath: string, @@ -217,7 +237,7 @@ const hasGeminiAccountCredentials = ( return Effect.succeed(true) } - return hasApiKeyInEnvFile(fs, `${accountPath}/.env`).pipe( + return hasApiKeyInEnvFile(fs, `${accountPath}/.env`, "GEMINI_API_KEY").pipe( Effect.flatMap((hasEnvApiKey) => { if (hasEnvApiKey) { return Effect.succeed(true) @@ -232,6 +252,62 @@ const hasGeminiAccountCredentials = ( }) ) +const hasGrokUserSettingsCredentials = ( + fs: FileSystem.FileSystem, + settingsPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, settingsPath)) + if (!hasFile) { + return false + } + + const settingsText = yield* _(fs.readFileString(settingsPath), Effect.orElseSucceed(() => "")) + return hasGrokUserSettingsCredentialText(settingsText) + }) + +const hasGrokAuthJsonCredentials = ( + fs: FileSystem.FileSystem, + authJsonPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, authJsonPath)) + if (!hasFile) { + return false + } + + const authJsonText = yield* _(fs.readFileString(authJsonPath), Effect.orElseSucceed(() => "")) + return hasGrokAuthJsonCredentialText(authJsonText) + }) + +const hasGrokAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyApiKeyFile(fs, `${accountPath}/.api-key`).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + + return Effect.forEach(grokEnvApiKeyNames, (key) => hasApiKeyInEnvFile(fs, `${accountPath}/.env`, key)).pipe( + Effect.map((results) => results.some((result) => result)), + Effect.flatMap((hasEnvApiKey) => { + if (hasEnvApiKey) { + return Effect.succeed(true) + } + return hasGrokAuthJsonCredentials(fs, `${accountPath}/.grok/auth.json`).pipe( + Effect.flatMap((hasAuthJson) => + hasAuthJson + ? Effect.succeed(true) + : hasGrokUserSettingsCredentials(fs, `${accountPath}/.grok/user-settings.json`) + ) + ) + }) + ) + }) + ) + const resolveAccountCandidates = (authPath: string, accountLabel: string): ReadonlyArray => accountLabel === "default" ? [`${authPath}/default`, authPath] : [`${authPath}/${accountLabel}`] @@ -298,23 +374,27 @@ export const readProjectAuthSnapshot = ( Effect.flatMap(({ fs, path, globalEnvText, projectEnvText }) => Effect.all({ claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot) + geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), + grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) }).pipe( - Effect.map(({ claudeAuthEntries, geminiAuthEntries }) => ({ + Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ projectDir: project.projectDir, projectName: project.displayName, envGlobalPath: globalEnvPath, envProjectPath: project.envProjectPath, claudeAuthPath: claudeAuthRoot, geminiAuthPath: geminiAuthRoot, + grokAuthPath: grokAuthRoot, githubTokenEntries: countKeyEntries(globalEnvText, githubTokenBaseKey), gitTokenEntries: countKeyEntries(globalEnvText, gitTokenBaseKey), claudeAuthEntries, geminiAuthEntries, + grokAuthEntries, activeGithubLabel: findEnvValue(projectEnvText, projectGithubLabelKey), activeGitLabel: findEnvValue(projectEnvText, projectGitLabelKey), activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey), - activeGeminiLabel: findEnvValue(projectEnvText, projectGeminiLabelKey) + activeGeminiLabel: findEnvValue(projectEnvText, projectGeminiLabelKey), + activeGrokLabel: findEnvValue(projectEnvText, projectGrokLabelKey) })) ) ) @@ -330,6 +410,8 @@ const resolveSyncMessage = (flow: ProjectAuthFlow, label: string, displayName: s Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${displayName}`), Match.when("ProjectGeminiConnect", () => `chore(state): project auth gemini ${label} ${displayName}`), Match.when("ProjectGeminiDisconnect", () => `chore(state): project auth gemini logout ${displayName}`), + Match.when("ProjectGrokConnect", () => `chore(state): project auth grok ${label} ${displayName}`), + Match.when("ProjectGrokDisconnect", () => `chore(state): project auth grok logout ${displayName}`), Match.exhaustive ) @@ -405,6 +487,21 @@ const resolveProjectEnvUpdate = ( Match.when("ProjectGeminiDisconnect", () => Effect.succeed(upsertEnvKey(projectEnvText, projectGeminiLabelKey, "")) ), + Match.when("ProjectGrokConnect", () => + findFirstCredentialsMatch( + fs, + resolveAccountCandidates(grokAuthRoot, normalizeAccountLabel(request.label ?? null, "default")), + hasGrokAccountCredentials + ).pipe( + Effect.flatMap((matched) => + matched === null + ? Effect.fail(missingSecret("Grok credentials (.api-key, GROK_DEPLOYMENT_KEY, or auth.json)", normalizedLabel, grokAuthRoot)) + : Effect.succeed(upsertEnvKey(projectEnvText, projectGrokLabelKey, normalizedLabel)) + ) + )), + Match.when("ProjectGrokDisconnect", () => + Effect.succeed(upsertEnvKey(projectEnvText, projectGrokLabelKey, "")) + ), Match.exhaustive ) }), diff --git a/packages/api/src/services/project-prompts.ts b/packages/api/src/services/project-prompts.ts index 5bbc84a6..19685271 100644 --- a/packages/api/src/services/project-prompts.ts +++ b/packages/api/src/services/project-prompts.ts @@ -6,7 +6,7 @@ import { Effect, pipe } from "effect" import type { ProjectDetails } from "../api/contracts.js" import { ApiBadRequestError } from "../api/errors.js" -export type ProjectPromptKind = "claude" | "codex" | "gemini" +export type ProjectPromptKind = "claude" | "codex" | "gemini" | "grok" export type ProjectPromptFile = { readonly kind: ProjectPromptKind @@ -28,7 +28,8 @@ export type ProjectPromptsSnapshot = { const promptDescriptors: ReadonlyArray<{ kind: ProjectPromptKind; fileName: string }> = [ { kind: "claude", fileName: "CLAUDE.md" }, { kind: "codex", fileName: "AGENTS.md" }, - { kind: "gemini", fileName: "GEMINI.md" } + { kind: "gemini", fileName: "GEMINI.md" }, + { kind: "grok", fileName: "GROK.md" } ] const maxPromptBytes = 1024 * 256 diff --git a/packages/api/src/services/project-skills.ts b/packages/api/src/services/project-skills.ts index 5e9f5982..db200e2c 100644 --- a/packages/api/src/services/project-skills.ts +++ b/packages/api/src/services/project-skills.ts @@ -13,6 +13,7 @@ export type ProjectSkillScope = | "claude/skills" | "codex/skills" | "gemini/skills" + | "grok/skills" export type ProjectSkillFile = { readonly id: string @@ -39,7 +40,8 @@ const skillScopes: ReadonlyArray<{ scope: ProjectSkillScope; relativeRoot: strin { scope: "agents/.skills", relativeRoot: ".agents/.skills" }, { scope: "claude/skills", relativeRoot: ".claude/skills" }, { scope: "codex/skills", relativeRoot: ".codex/skills" }, - { scope: "gemini/skills", relativeRoot: ".gemini/skills" } + { scope: "gemini/skills", relativeRoot: ".gemini/skills" }, + { scope: "grok/skills", relativeRoot: ".grok/skills" } ] const skillFileName = "SKILL.md" diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index 89e6b7c5..a20c001a 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -438,6 +438,8 @@ const toCreateRawOptions = (request: CreateProjectRequest): RawOptions => ({ ...(request.skipGithubAuth === undefined ? {} : { skipGithubAuth: request.skipGithubAuth }), ...(request.codexTokenLabel === undefined ? {} : { codexTokenLabel: request.codexTokenLabel }), ...(request.claudeTokenLabel === undefined ? {} : { claudeTokenLabel: request.claudeTokenLabel }), + ...(request.geminiTokenLabel === undefined ? {} : { geminiTokenLabel: request.geminiTokenLabel }), + ...(request.grokTokenLabel === undefined ? {} : { grokTokenLabel: request.grokTokenLabel }), ...(request.agentAutoMode === undefined ? {} : { agentAutoMode: request.agentAutoMode }), ...(request.up === undefined ? {} : { up: request.up }), ...(request.openSsh === undefined ? {} : { openSsh: request.openSsh }), diff --git a/packages/api/tests/agents.test.ts b/packages/api/tests/agents.test.ts index 35f6d9b7..438d4b87 100644 --- a/packages/api/tests/agents.test.ts +++ b/packages/api/tests/agents.test.ts @@ -17,6 +17,13 @@ describe("agent service", () => { ) }) + it("starts default Grok agents with isolated Playwright MCP and unrestricted sandbox", () => { + expect(buildCommand({ provider: "grok" })).toBe("MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox") + expect(buildCommand({ provider: "grok", args: ["-p", "hello world"] })).toBe( + "MCP_PLAYWRIGHT_ISOLATED=1 grok --no-sandbox '-p' 'hello world'" + ) + }) + it("starts default OpenCode agents without extra env assignments", () => { expect(buildCommand({ provider: "opencode" })).toBe("opencode") }) diff --git a/packages/api/tests/project-auth.test.ts b/packages/api/tests/project-auth.test.ts new file mode 100644 index 00000000..98355c22 --- /dev/null +++ b/packages/api/tests/project-auth.test.ts @@ -0,0 +1,322 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import type { PlatformError } from "@effect/platform/Error" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Scope from "effect/Scope" +import fc from "fast-check" +import { vi } from "vitest" + +import type { ProjectDetails } from "../src/api/contracts.js" + +const grokOidcAuthScope = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828" +const grokLegacyAuthScope = "https://accounts.x.ai/sign-in" + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect> => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-api-project-auth-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const withProjectsRoot = ( + projectsRoot: string, + effect: Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease( + Effect.sync(() => { + const previous = process.env["DOCKER_GIT_PROJECTS_ROOT"] + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + return previous + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = previous + } + }) + ).pipe(Effect.flatMap(() => effect)) + ) + +const buildProjectDetails = (projectDir: string, envProjectPath: string, envGlobalPath: string): ProjectDetails => ({ + id: projectDir, + projectKey: "org/repo", + displayName: "org/repo", + repoUrl: "https://git.example.test/org/repo.git", + repoRef: "main", + containerName: "dg-project-auth-test", + serviceName: "dg-project-auth-test", + status: "stopped", + statusLabel: "stopped", + sshSessions: 0, + startedAtIso: null, + startedAtEpochMs: null, + sshUser: "dev", + sshPort: 2222, + gpu: "none", + targetDir: "/home/dev/app", + projectDir, + sshCommand: "ssh dev@localhost", + authorizedKeysPath: `${projectDir}/authorized_keys`, + authorizedKeysExists: false, + envGlobalPath, + envProjectPath, + codexAuthPath: `${projectDir}/codex`, + codexHome: "/home/dev/.codex" +}) + +const runGrokApiKeyConnectCase = (apiKey: string) => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const grokDefaultAuth = path.join(projectsRoot, ".orch", "auth", "grok", "default") + const projectDir = path.join(projectsRoot, "org", "repo") + const envGlobalPath = path.join(envDir, "global.env") + const envProjectPath = path.join(projectDir, ".env") + const project = buildProjectDetails(projectDir, envProjectPath, envGlobalPath) + + yield* _(fs.makeDirectory(grokDefaultAuth, { recursive: true })) + yield* _(fs.makeDirectory(projectDir, { recursive: true })) + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(grokDefaultAuth, ".api-key"), apiKey)) + yield* _(fs.writeFileString(envGlobalPath, "# docker-git env\n")) + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + + const service = yield* _( + withProjectsRoot( + projectsRoot, + Effect.gen(function*(_) { + yield* _(Effect.sync(() => vi.resetModules())) + return yield* _(Effect.promise(() => import("../src/services/project-auth.js"))) + }) + ) + ) + const result = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }) + ).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "failure" as const, errorTag: error._tag }), + onSuccess: (snapshot) => ({ _tag: "success" as const, activeGrokLabel: snapshot.activeGrokLabel }) + }) + ) + ) + const envText = yield* _(fs.readFileString(envProjectPath)) + + return { result, envText } + }) + ) + +describe("project auth service", () => { + it.effect("preserves Grok project API-key connect invariants", () => + Effect.tryPromise({ + catch: (error) => error, + try: () => + fc.assert( + fc.asyncProperty( + fc.oneof(fc.string(), fc.constant(""), fc.constant(" "), fc.constant("\t\n")), + (apiKey) => + Effect.runPromise( + runGrokApiKeyConnectCase(apiKey).pipe( + Effect.provide(NodeContext.layer), + Effect.map(({ result, envText }) => { + if (apiKey.trim().length === 0) { + expect(result._tag).toBe("failure") + if (result._tag === "failure") { + expect(result.errorTag).toBe("ApiBadRequestError") + } + expect(envText).not.toContain("GROK_AUTH_LABEL=default") + return + } + + expect(result._tag).toBe("success") + if (result._tag === "success") { + expect(result.activeGrokLabel).toBe("default") + } + expect(envText).toContain("GROK_AUTH_LABEL=default") + }) + ) + ) + ), + { numRuns: 20 } + ) + })) + + it.effect("requires a non-empty Grok .api-key before connecting an account", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const envDir = path.join(projectsRoot, ".orch", "env") + const grokDefaultAuth = path.join(projectsRoot, ".orch", "auth", "grok", "default") + const projectDir = path.join(projectsRoot, "org", "repo") + const envGlobalPath = path.join(envDir, "global.env") + const envProjectPath = path.join(projectDir, ".env") + const project = buildProjectDetails(projectDir, envProjectPath, envGlobalPath) + + yield* _(fs.makeDirectory(grokDefaultAuth, { recursive: true })) + yield* _(fs.makeDirectory(projectDir, { recursive: true })) + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(grokDefaultAuth, ".api-key"), " \n")) + yield* _(fs.writeFileString(envGlobalPath, "# docker-git env\n")) + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + + const service = yield* _( + withProjectsRoot( + projectsRoot, + Effect.gen(function*(_) { + yield* _(Effect.sync(() => vi.resetModules())) + return yield* _(Effect.promise(() => import("../src/services/project-auth.js"))) + }) + ) + ) + const failure = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }).pipe(Effect.flip) + ) + ) + + expect(failure._tag).toBe("ApiBadRequestError") + expect(failure.message).toContain("Grok credentials") + expect(yield* _(fs.readFileString(envProjectPath))).not.toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(path.join(grokDefaultAuth, ".api-key"), "live-token\n")) + const snapshot = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }) + ) + ) + + expect(snapshot.activeGrokLabel).toBe("default") + expect(yield* _(fs.readFileString(envProjectPath))).toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + yield* _(fs.remove(path.join(grokDefaultAuth, ".api-key"))) + yield* _(fs.makeDirectory(path.join(grokDefaultAuth, ".grok"), { recursive: true })) + yield* _( + fs.writeFileString( + path.join(grokDefaultAuth, ".grok", "auth.json"), + `${JSON.stringify({ [grokOidcAuthScope]: { key: "xai-oauth" } })}\n` + ) + ) + + const oauthSnapshot = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }) + ) + ) + + expect(oauthSnapshot.activeGrokLabel).toBe("default") + expect(yield* _(fs.readFileString(envProjectPath))).toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + yield* _( + fs.writeFileString( + path.join(grokDefaultAuth, ".grok", "auth.json"), + `${JSON.stringify({ [grokLegacyAuthScope]: { key: "xai-legacy" } })}\n` + ) + ) + + const legacyOauthSnapshot = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }) + ) + ) + + expect(legacyOauthSnapshot.activeGrokLabel).toBe("default") + expect(yield* _(fs.readFileString(envProjectPath))).toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + yield* _(fs.writeFileString(path.join(grokDefaultAuth, ".grok", "auth.json"), "{\"scope\":{\"key\":\"xai-oauth\"}}\n")) + + const arbitraryAuthJsonFailure = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }).pipe(Effect.flip) + ) + ) + + expect(arbitraryAuthJsonFailure._tag).toBe("ApiBadRequestError") + expect(yield* _(fs.readFileString(envProjectPath))).not.toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + yield* _(fs.remove(path.join(grokDefaultAuth, ".grok"), { recursive: true })) + yield* _(fs.writeFileString(path.join(grokDefaultAuth, ".env"), "GROK_DEPLOYMENT_KEY='xai-deploy'\n")) + + const envSnapshot = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }) + ) + ) + + expect(envSnapshot.activeGrokLabel).toBe("default") + expect(yield* _(fs.readFileString(envProjectPath))).toContain("GROK_AUTH_LABEL=default") + + yield* _(fs.writeFileString(envProjectPath, "# project env\n")) + yield* _(fs.remove(path.join(grokDefaultAuth, ".env"))) + yield* _(fs.makeDirectory(path.join(grokDefaultAuth, ".grok"), { recursive: true })) + yield* _( + fs.writeFileString( + path.join(grokDefaultAuth, ".grok", "user-settings.json"), + "{\"oauth\":{},\"telemetry\":{\"token\":\"not-oauth\"}}\n" + ) + ) + + const falsePositiveFailure = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGrokConnect", + label: "default" + }).pipe(Effect.flip) + ) + ) + + expect(falsePositiveFailure._tag).toBe("ApiBadRequestError") + expect(yield* _(fs.readFileString(envProjectPath))).not.toContain("GROK_AUTH_LABEL=default") + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/api/tests/projects.test.ts b/packages/api/tests/projects.test.ts index 85c14435..32345064 100644 --- a/packages/api/tests/projects.test.ts +++ b/packages/api/tests/projects.test.ts @@ -207,7 +207,7 @@ describe("projects service", () => { expect(failure.message).not.toContain("docker-daemon-access.js") } }) - ).pipe(Effect.provide(NodeContext.layer))) + ).pipe(Effect.provide(NodeContext.layer)), 15_000) it.effect("accepts async create and records realtime lifecycle events on the request project id", () => withTempDir((root) => diff --git a/packages/app/src/docker-git/api-auth-codec.ts b/packages/app/src/docker-git/api-auth-codec.ts index c8ceeb86..b5eefe86 100644 --- a/packages/app/src/docker-git/api-auth-codec.ts +++ b/packages/app/src/docker-git/api-auth-codec.ts @@ -5,29 +5,36 @@ type RawAuthSnapshot = { readonly globalEnvPath: string | null readonly claudeAuthPath: string | null readonly geminiAuthPath: string | null + readonly grokAuthPath: string | null readonly totalEntries: number | null readonly githubTokenEntries: number | null readonly gitTokenEntries: number | null readonly gitUserEntries: number | null readonly claudeAuthEntries: number | null readonly geminiAuthEntries: number | null + readonly grokAuthEntries: number | null } type RawProjectAuthSnapshot = { + /* jscpd:ignore-start */ readonly projectDir: string | null readonly projectName: string | null readonly envGlobalPath: string | null readonly envProjectPath: string | null readonly claudeAuthPath: string | null readonly geminiAuthPath: string | null + readonly grokAuthPath: string | null readonly githubTokenEntries: number | null readonly gitTokenEntries: number | null readonly claudeAuthEntries: number | null readonly geminiAuthEntries: number | null + readonly grokAuthEntries: number | null readonly activeGithubLabel: string | null readonly activeGitLabel: string | null readonly activeClaudeLabel: string | null readonly activeGeminiLabel: string | null + readonly activeGrokLabel: string | null + /* jscpd:ignore-end */ } const readNumber = (value: JsonValue | undefined): number | null => typeof value === "number" ? value : null @@ -53,12 +60,14 @@ const readAuthSnapshot = ( globalEnvPath: asString(snapshot["globalEnvPath"]), claudeAuthPath: asString(snapshot["claudeAuthPath"]), geminiAuthPath: asString(snapshot["geminiAuthPath"]), + grokAuthPath: asString(snapshot["grokAuthPath"]), totalEntries: readNumber(snapshot["totalEntries"]), githubTokenEntries: readNumber(snapshot["githubTokenEntries"]), gitTokenEntries: readNumber(snapshot["gitTokenEntries"]), gitUserEntries: readNumber(snapshot["gitUserEntries"]), claudeAuthEntries: readNumber(snapshot["claudeAuthEntries"]), - geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]) + geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]), + grokAuthEntries: readNumber(snapshot["grokAuthEntries"]) } } @@ -71,12 +80,14 @@ const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | n globalEnvPath: stringOrEmpty(snapshot.globalEnvPath), claudeAuthPath: stringOrEmpty(snapshot.claudeAuthPath), geminiAuthPath: stringOrEmpty(snapshot.geminiAuthPath), + grokAuthPath: stringOrEmpty(snapshot.grokAuthPath), totalEntries: numberOrZero(snapshot.totalEntries), githubTokenEntries: numberOrZero(snapshot.githubTokenEntries), gitTokenEntries: numberOrZero(snapshot.gitTokenEntries), gitUserEntries: numberOrZero(snapshot.gitUserEntries), claudeAuthEntries: numberOrZero(snapshot.claudeAuthEntries), - geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries) + geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries), + grokAuthEntries: numberOrZero(snapshot.grokAuthEntries) } } @@ -94,14 +105,17 @@ const readProjectAuthSnapshot = ( envProjectPath: asString(snapshot["envProjectPath"]), claudeAuthPath: asString(snapshot["claudeAuthPath"]), geminiAuthPath: asString(snapshot["geminiAuthPath"]), + grokAuthPath: asString(snapshot["grokAuthPath"]), githubTokenEntries: readNumber(snapshot["githubTokenEntries"]), gitTokenEntries: readNumber(snapshot["gitTokenEntries"]), claudeAuthEntries: readNumber(snapshot["claudeAuthEntries"]), geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]), + grokAuthEntries: readNumber(snapshot["grokAuthEntries"]), activeGithubLabel: asString(snapshot["activeGithubLabel"]), activeGitLabel: asString(snapshot["activeGitLabel"]), activeClaudeLabel: asString(snapshot["activeClaudeLabel"]), - activeGeminiLabel: asString(snapshot["activeGeminiLabel"]) + activeGeminiLabel: asString(snapshot["activeGeminiLabel"]), + activeGrokLabel: asString(snapshot["activeGrokLabel"]) } } @@ -115,10 +129,12 @@ const decodeRequiredProjectAuthSnapshot = ( snapshot.envProjectPath, snapshot.claudeAuthPath, snapshot.geminiAuthPath, + snapshot.grokAuthPath, snapshot.githubTokenEntries, snapshot.gitTokenEntries, snapshot.claudeAuthEntries, - snapshot.geminiAuthEntries + snapshot.geminiAuthEntries, + snapshot.grokAuthEntries ] if (hasNullValue(requiredValues)) { @@ -132,14 +148,17 @@ const decodeRequiredProjectAuthSnapshot = ( envProjectPath: stringOrEmpty(snapshot.envProjectPath), claudeAuthPath: stringOrEmpty(snapshot.claudeAuthPath), geminiAuthPath: stringOrEmpty(snapshot.geminiAuthPath), + grokAuthPath: stringOrEmpty(snapshot.grokAuthPath), githubTokenEntries: numberOrZero(snapshot.githubTokenEntries), gitTokenEntries: numberOrZero(snapshot.gitTokenEntries), claudeAuthEntries: numberOrZero(snapshot.claudeAuthEntries), geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries), + grokAuthEntries: numberOrZero(snapshot.grokAuthEntries), activeGithubLabel: snapshot.activeGithubLabel, activeGitLabel: snapshot.activeGitLabel, activeClaudeLabel: snapshot.activeClaudeLabel, - activeGeminiLabel: snapshot.activeGeminiLabel + activeGeminiLabel: snapshot.activeGeminiLabel, + activeGrokLabel: snapshot.activeGrokLabel } } diff --git a/packages/app/src/docker-git/api-client-create.ts b/packages/app/src/docker-git/api-client-create.ts index de19cbd7..b49413f2 100644 --- a/packages/app/src/docker-git/api-client-create.ts +++ b/packages/app/src/docker-git/api-client-create.ts @@ -47,6 +47,8 @@ export const buildCreateProjectRequest = ( useManagedAuthorizedKeys: true, codexTokenLabel: config.codexAuthLabel, claudeTokenLabel: config.claudeAuthLabel, + geminiTokenLabel: config.geminiAuthLabel, + grokTokenLabel: config.grokAuthLabel, agentAutoMode: config.agentAuto ? (config.agentMode ?? "auto") : undefined, up: command.runUp, openSsh: false, diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 286e4997..ba63a5ac 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -258,7 +258,7 @@ export const createProjectTerminalSession = (projectId: string) => ) export const createAuthTerminalSession = ( - flow: "ClaudeOauth" | "GeminiOauth", + flow: "ClaudeOauth" | "GeminiOauth" | "GrokOauth", label: string | null ) => request("POST", "/auth/terminal-sessions", { flow, label: label ?? undefined }).pipe( diff --git a/packages/app/src/docker-git/cli/parser-apply.ts b/packages/app/src/docker-git/cli/parser-apply.ts index 86b1f231..3e6ac8b5 100644 --- a/packages/app/src/docker-git/cli/parser-apply.ts +++ b/packages/app/src/docker-git/cli/parser-apply.ts @@ -33,6 +33,8 @@ export const parseApply = ( gitTokenLabel: raw.gitTokenLabel, codexTokenLabel: raw.codexTokenLabel, claudeTokenLabel: raw.claudeTokenLabel, + geminiTokenLabel: raw.geminiTokenLabel, + grokTokenLabel: raw.grokTokenLabel, cpuLimit, ramLimit, playwrightCpuLimit, diff --git a/packages/app/src/docker-git/cli/parser-auth.ts b/packages/app/src/docker-git/cli/parser-auth.ts index 1babd3fd..539713a4 100644 --- a/packages/app/src/docker-git/cli/parser-auth.ts +++ b/packages/app/src/docker-git/cli/parser-auth.ts @@ -11,6 +11,7 @@ type AuthOptions = { readonly codexAuthPath: string readonly claudeAuthPath: string readonly geminiAuthPath: string + readonly grokAuthPath: string readonly label: string | null readonly token: string | null readonly scopes: string | null @@ -43,12 +44,14 @@ const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env" const defaultCodexAuthPath = ".docker-git/.orch/auth/codex" const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude" const defaultGeminiAuthPath = ".docker-git/.orch/auth/gemini" +const defaultGrokAuthPath = ".docker-git/.orch/auth/grok" const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({ envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath, codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath, claudeAuthPath: defaultClaudeAuthPath, geminiAuthPath: defaultGeminiAuthPath, + grokAuthPath: defaultGrokAuthPath, label: normalizeOptionalText(raw.label), token: normalizeOptionalText(raw.token), scopes: normalizeOptionalText(raw.scopes), @@ -191,6 +194,40 @@ const buildGeminiCommand = (action: string, options: AuthOptions): Either.Either Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) ) +// CHANGE: add Grok CLI auth command parsing +// WHY: issue #304 requires docker-git auth grok login/status/logout support +// QUOTE(ТЗ): "Реализовать поддержку авторизации grok" +// REF: issue-304 +// SOURCE: https://x.ai/news/grok-build-cli +// FORMAT THEOREM: forall action: buildGrokCommand(action, opts) = AuthCommand | ParseError +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: grokAuthPath is always set from defaults +// COMPLEXITY: O(1) +const buildGrokCommand = (action: string, options: AuthOptions): Either.Either => + Match.value(action).pipe( + Match.when("login", () => + Either.right({ + _tag: "AuthGrokLogin", + label: options.label, + grokAuthPath: options.grokAuthPath, + isWeb: options.authWeb + })), + Match.when("status", () => + Either.right({ + _tag: "AuthGrokStatus", + label: options.label, + grokAuthPath: options.grokAuthPath + })), + Match.when("logout", () => + Either.right({ + _tag: "AuthGrokLogout", + label: options.label, + grokAuthPath: options.grokAuthPath + })), + Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))) + ) + const buildAuthCommand = ( provider: string, action: string, @@ -204,6 +241,7 @@ const buildAuthCommand = ( Match.when("claude", () => buildClaudeCommand(action, options)), Match.when("cc", () => buildClaudeCommand(action, options)), Match.when("gemini", () => buildGeminiCommand(action, options)), + Match.when("grok", () => buildGrokCommand(action, options)), Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`))) ) diff --git a/packages/app/src/docker-git/cli/parser-options.ts b/packages/app/src/docker-git/cli/parser-options.ts index 2fa9ec85..bf69872d 100644 --- a/packages/app/src/docker-git/cli/parser-options.ts +++ b/packages/app/src/docker-git/cli/parser-options.ts @@ -33,6 +33,8 @@ interface ValueOptionSpec { | "gitTokenLabel" | "codexTokenLabel" | "claudeTokenLabel" + | "geminiTokenLabel" + | "grokTokenLabel" | "token" | "scopes" | "message" @@ -76,6 +78,8 @@ const valueOptionSpecs: ReadonlyArray = [ { flag: "--git-token", key: "gitTokenLabel" }, { flag: "--codex-token", key: "codexTokenLabel" }, { flag: "--claude-token", key: "claudeTokenLabel" }, + { flag: "--gemini-token", key: "geminiTokenLabel" }, + { flag: "--grok-token", key: "grokTokenLabel" }, { flag: "--token", key: "token" }, { flag: "--scopes", key: "scopes" }, { flag: "--message", key: "message" }, @@ -137,6 +141,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st gitTokenLabel: (raw, value) => ({ ...raw, gitTokenLabel: value }), codexTokenLabel: (raw, value) => ({ ...raw, codexTokenLabel: value }), claudeTokenLabel: (raw, value) => ({ ...raw, claudeTokenLabel: value }), + geminiTokenLabel: (raw, value) => ({ ...raw, geminiTokenLabel: value }), + grokTokenLabel: (raw, value) => ({ ...raw, grokTokenLabel: value }), token: (raw, value) => ({ ...raw, token: value }), scopes: (raw, value) => ({ ...raw, scopes: value }), message: (raw, value) => ({ ...raw, message: value }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 59fa7b13..2bde89d7 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -34,7 +34,7 @@ Commands: ps, status Show docker compose status for all docker-git projects apply-all Apply docker-git config and refresh all containers (docker compose up); use --active to restrict to running containers only down-all Stop all docker-git containers (docker compose down) - auth Manage GitHub/GitLab/Codex/Claude Code auth for docker-git + auth Manage GitHub/GitLab/Codex/Claude Code/Gemini/Grok auth for docker-git state Manage docker-git state directory via git (sync across machines) Options: @@ -70,13 +70,15 @@ Options: --gh-skip Skip GitHub auth for public clone/create and force anonymous HTTPS clone --codex-token