From b81f2806593ad6fd110184cf2ad05a4b492cf5b9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 12:24:44 +0000 Subject: [PATCH 1/7] feat(auth): unify controller oauth flows --- packages/api/src/api/contracts.ts | 2 + .../api/src/services/auth-account-counts.ts | 290 ++++++++++++++++++ packages/api/src/services/auth-menu.ts | 47 +-- packages/api/src/services/project-auth.ts | 38 +-- packages/api/tests/auth-menu.test.ts | 102 ++++++ packages/api/tests/project-auth.test.ts | 108 +++++++ packages/app/src/docker-git/api-auth-codec.ts | 21 +- .../app/src/docker-git/api-client-auth.ts | 23 +- .../app/src/docker-git/menu-auth-effects.ts | 14 +- .../app/src/docker-git/menu-auth-helpers.ts | 86 +++++- .../app/src/docker-git/menu-auth-shared.ts | 17 +- .../docker-git/menu-auth-snapshot-builder.ts | 14 +- packages/app/src/docker-git/menu-auth.ts | 4 +- .../docker-git/menu-project-auth-gemini.ts | 74 +---- .../menu-project-auth-grok-credential-text.ts | 77 +++++ .../src/docker-git/menu-project-auth-grok.ts | 61 ++++ .../docker-git/menu-project-auth-helpers.ts | 78 ++++- .../app/src/docker-git/menu-render-auth.ts | 5 + packages/app/src/docker-git/menu-types.ts | 4 + packages/app/src/docker-git/program-auth.ts | 48 ++- .../app/src/docker-git/program-unsupported.ts | 33 +- .../templates-entrypoint/nested-docker-git.ts | 5 +- .../lib/usecases/actions/create-project.ts | 9 +- .../app/src/lib/usecases/actions/paths.ts | 14 +- .../src/lib/usecases/actions/prepare-files.ts | 4 +- .../app/src/lib/usecases/auth-grok-oauth.ts | 10 +- packages/app/src/lib/usecases/auth-sync.ts | 10 + .../src/lib/usecases/shared-volume-seed.ts | 140 ++++++--- .../app/src/lib/usecases/state-normalize.ts | 8 +- .../app/src/shared/auth-stream-markers.ts | 8 + packages/app/src/web/actions-auth.ts | 34 ++ packages/app/src/web/actions-codex-oauth.ts | 55 ++++ packages/app/src/web/api-auth-schema.ts | 2 + packages/app/src/web/api.ts | 11 + packages/app/src/web/panel-auth.tsx | 3 +- .../app/tests/docker-git/actions-auth.test.ts | 103 +++++-- .../docker-git/actions-codex-oauth.test.ts | 70 +++++ .../docker-git/actions-github-oauth.test.ts | 2 + .../tests/docker-git/api-auth-schema.test.ts | 4 +- .../docker-git/app-ready-create-fixture.ts | 2 +- .../docker-git/auth-stream-markers.test.ts | 16 + .../tests/docker-git/core-templates.test.ts | 9 + .../docker-git/create-flow-render.test.ts | 2 +- packages/app/tests/docker-git/program.test.ts | 52 ++++ .../templates-entrypoint/nested-docker-git.ts | 5 +- .../src/usecases/actions/create-project.ts | 9 +- packages/lib/src/usecases/actions/paths.ts | 14 +- .../lib/src/usecases/actions/prepare-files.ts | 4 +- packages/lib/src/usecases/auth-grok-oauth.ts | 34 +- packages/lib/src/usecases/auth-sync.ts | 10 + .../lib/src/usecases/shared-volume-seed.ts | 140 ++++++--- packages/lib/src/usecases/state-normalize.ts | 8 +- packages/lib/tests/core/templates.test.ts | 13 + packages/lib/tests/usecases/auth-grok.test.ts | 4 +- packages/lib/tests/usecases/auth-sync.test.ts | 38 +++ .../tests/usecases/shared-volume-seed.test.ts | 48 ++- .../tests/usecases/state-normalize.test.ts | 66 ++++ 57 files changed, 1744 insertions(+), 368 deletions(-) create mode 100644 packages/api/src/services/auth-account-counts.ts create mode 100644 packages/api/tests/auth-menu.test.ts create mode 100644 packages/app/src/docker-git/menu-project-auth-grok-credential-text.ts create mode 100644 packages/app/src/docker-git/menu-project-auth-grok.ts create mode 100644 packages/app/src/web/actions-codex-oauth.ts create mode 100644 packages/app/tests/docker-git/actions-codex-oauth.test.ts create mode 100644 packages/lib/tests/usecases/state-normalize.test.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index ac6336b6..bc12ab14 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -208,6 +208,7 @@ export type AuthTerminalFlow = "ClaudeOauth" | "GeminiOauth" | "GrokOauth" export type AuthSnapshot = { readonly globalEnvPath: string readonly claudeAuthPath: string + readonly codexAuthPath: string readonly geminiAuthPath: string readonly grokAuthPath: string readonly totalEntries: number @@ -215,6 +216,7 @@ export type AuthSnapshot = { readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number + readonly codexAuthEntries: number readonly geminiAuthEntries: number readonly grokAuthEntries: number } diff --git a/packages/api/src/services/auth-account-counts.ts b/packages/api/src/services/auth-account-counts.ts new file mode 100644 index 00000000..ee50b47a --- /dev/null +++ b/packages/api/src/services/auth-account-counts.ts @@ -0,0 +1,290 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import { hasGrokAuthJsonCredentialText, hasGrokUserSettingsCredentialText } from "@effect-template/lib/usecases/auth-grok-credential-text" + +type HasCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +) => Effect.Effect + +const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) +const grokEnvApiKeyNames: ReadonlyArray = ["GROK_DEPLOYMENT_KEY", "GROK_API_KEY", "XAI_API_KEY"] + +const hasFileAtPath = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) + +const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, filePath)) + if (!hasFile) { + return false + } + const content = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return content.trim().length > 0 + }) + +const hasApiKeyInEnvFile = ( + fs: FileSystem.FileSystem, + envFilePath: string, + key: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) + if (!hasFile) { + return false + } + 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(prefix)) { + continue + } + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return true + } + } + return false + }) + +const hasAnyFile = ( + fs: FileSystem.FileSystem, + basePath: string, + fileNames: ReadonlyArray +): Effect.Effect => + Effect.gen(function*(_) { + for (const fileName of fileNames) { + const hasFile = yield* _(hasFileAtPath(fs, `${basePath}/${fileName}`)) + if (hasFile) { + return true + } + } + return false + }) + +const hasLegacyClaudeAuthFile = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(accountPath)) + if (!exists) { + return false + } + const entries = yield* _(fs.readDirectory(accountPath)) + for (const entry of entries) { + if (!entry.startsWith(".claude") || !entry.endsWith(".json")) { + continue + } + const isFile = yield* _(hasFileAtPath(fs, `${accountPath}/${entry}`)) + if (isFile) { + return true + } + } + return false + }) + +export const hasClaudeAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasFileAtPath(fs, `${accountPath}/.credentials.json`).pipe( + Effect.flatMap((hasCredentialsFile) => { + if (hasCredentialsFile) { + return Effect.succeed(true) + } + return hasFileAtPath(fs, `${accountPath}/.claude/.credentials.json`) + }), + Effect.flatMap((hasNestedCredentialsFile) => { + if (hasNestedCredentialsFile) { + return Effect.succeed(true) + } + return hasFileAtPath(fs, `${accountPath}/.config.json`) + }), + Effect.flatMap((hasConfig) => { + if (hasConfig) { + return Effect.succeed(true) + } + return hasNonEmptyFile(fs, `${accountPath}/.oauth-token`).pipe( + Effect.flatMap((hasOauthToken) => hasOauthToken ? Effect.succeed(true) : hasLegacyClaudeAuthFile(fs, accountPath)) + ) + }) + ) + +export const hasGeminiAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyFile(fs, `${accountPath}/.api-key`).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + return hasApiKeyInEnvFile(fs, `${accountPath}/.env`, "GEMINI_API_KEY").pipe( + Effect.flatMap((hasEnvApiKey) => + hasEnvApiKey + ? Effect.succeed(true) + : hasAnyFile(fs, `${accountPath}/.gemini`, [ + "oauth_creds.json", + "oauth-tokens.json", + "credentials.json", + "application_default_credentials.json" + ]) + ) + ) + }) + ) + +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 hasGrokEnvApiKey = ( + fs: FileSystem.FileSystem, + envFilePath: string +): Effect.Effect => + Effect.gen(function*(_) { + for (const key of grokEnvApiKeyNames) { + const hasApiKey = yield* _(hasApiKeyInEnvFile(fs, envFilePath, key)) + if (hasApiKey) { + return true + } + } + return false + }) + +export const hasGrokAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyFile(fs, `${accountPath}/.api-key`).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + return hasGrokEnvApiKey(fs, `${accountPath}/.env`).pipe( + 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`) + ) + ) + }) + ) + }) + ) + +export const hasCodexAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyFile(fs, `${accountPath}/auth.json`) + +export const countCodexCredentialAccounts = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(root)) + if (!exists) { + return 0 + } + + let count = yield* _(hasCodexAccountCredentials(fs, root), Effect.map((connected) => connected ? 1 : 0)) + const entries = yield* _(fs.readDirectory(root)) + for (const entry of entries) { + if (ignoredAuthAccountEntries.has(entry)) { + continue + } + + const accountPath = path.join(root, entry) + const info = yield* _(fs.stat(accountPath)) + if (info.type !== "Directory") { + continue + } + + const connected = yield* _(hasCodexAccountCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (connected) { + count += 1 + } + } + return count + }) + +export const countAuthCredentialAccounts = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + hasCredentials: HasCredentials +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(root)) + if (!exists) { + return 0 + } + + const entries = yield* _(fs.readDirectory(root)) + let count = 0 + for (const entry of entries) { + if (ignoredAuthAccountEntries.has(entry)) { + continue + } + + const accountPath = path.join(root, entry) + const info = yield* _(fs.stat(accountPath)) + if (info.type !== "Directory") { + continue + } + + const connected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (connected) { + count += 1 + } + } + return count + }) diff --git a/packages/api/src/services/auth-menu.ts b/packages/api/src/services/auth-menu.ts index 4a86ce3b..f9c7faf8 100644 --- a/packages/api/src/services/auth-menu.ts +++ b/packages/api/src/services/auth-menu.ts @@ -11,10 +11,18 @@ import { Effect, pipe } from "effect" import type { AuthMenuRequest, AuthSnapshot } from "../api/contracts.js" import { ApiBadRequestError } from "../api/errors.js" +import { + countAuthCredentialAccounts, + countCodexCredentialAccounts, + hasClaudeAccountCredentials, + hasGeminiAccountCredentials, + hasGrokAccountCredentials +} from "./auth-account-counts.js" type MenuAuthRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor const claudeAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/claude` +const codexAuthRoot = `${defaultProjectsRoot(process.cwd())}/.orch/auth/codex` 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` @@ -52,34 +60,6 @@ const countKeyEntries = (envText: string, baseKey: string): number => { .length } -const countAuthAccountDirectories = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(root)) - if (!exists) { - return 0 - } - - const entries = yield* _(fs.readDirectory(root)) - let count = 0 - for (const entry of entries) { - if (entry === ".image") { - continue - } - - const fullPath = path.join(root, entry) - const info = yield* _(fs.stat(fullPath)) - if (info.type === "Directory") { - count += 1 - } - } - - return count - }) - const loadAuthEnvText = (): Effect.Effect< { readonly fs: FileSystem.FileSystem @@ -102,13 +82,15 @@ export const readAuthMenuSnapshot = (): Effect.Effect Effect.all({ - claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), - grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) + claudeAuthEntries: countAuthCredentialAccounts(fs, path, claudeAuthRoot, hasClaudeAccountCredentials), + codexAuthEntries: countCodexCredentialAccounts(fs, path, codexAuthRoot), + geminiAuthEntries: countAuthCredentialAccounts(fs, path, geminiAuthRoot, hasGeminiAccountCredentials), + grokAuthEntries: countAuthCredentialAccounts(fs, path, grokAuthRoot, hasGrokAccountCredentials) }).pipe( - Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ + Effect.map(({ claudeAuthEntries, codexAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ globalEnvPath, claudeAuthPath: claudeAuthRoot, + codexAuthPath: codexAuthRoot, geminiAuthPath: geminiAuthRoot, grokAuthPath: grokAuthRoot, totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length, @@ -116,6 +98,7 @@ export const readAuthMenuSnapshot = (): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(root)) - if (!exists) { - return 0 - } - - const entries = yield* _(fs.readDirectory(root)) - let count = 0 - for (const entry of entries) { - if (entry === ".image") { - continue - } - - const fullPath = path.join(root, entry) - const info = yield* _(fs.stat(fullPath)) - if (info.type === "Directory") { - count += 1 - } - } - - return count - }) - const hasNonEmptyOauthToken = ( fs: FileSystem.FileSystem, tokenPath: string @@ -231,7 +204,7 @@ const hasGeminiAccountCredentials = ( fs: FileSystem.FileSystem, accountPath: string ): Effect.Effect => - hasFileAtPath(fs, `${accountPath}/.api-key`).pipe( + hasNonEmptyApiKeyFile(fs, `${accountPath}/.api-key`).pipe( Effect.flatMap((hasApiKey) => { if (hasApiKey) { return Effect.succeed(true) @@ -243,6 +216,7 @@ const hasGeminiAccountCredentials = ( return Effect.succeed(true) } return checkAnyFileExists(fs, `${accountPath}/.gemini`, [ + "oauth_creds.json", "oauth-tokens.json", "credentials.json", "application_default_credentials.json" @@ -373,9 +347,9 @@ export const readProjectAuthSnapshot = ( loadProjectAuthEnvText(project), Effect.flatMap(({ fs, path, globalEnvText, projectEnvText }) => Effect.all({ - claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthRoot), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthRoot), - grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthRoot) + claudeAuthEntries: countAuthCredentialAccounts(fs, path, claudeAuthRoot, hasClaudeAccountCredentials), + geminiAuthEntries: countAuthCredentialAccounts(fs, path, geminiAuthRoot, hasGeminiAccountCredentials), + grokAuthEntries: countAuthCredentialAccounts(fs, path, grokAuthRoot, hasGrokAccountCredentials) }).pipe( Effect.map(({ claudeAuthEntries, geminiAuthEntries, grokAuthEntries }) => ({ projectDir: project.projectDir, diff --git a/packages/api/tests/auth-menu.test.ts b/packages/api/tests/auth-menu.test.ts new file mode 100644 index 00000000..218190e6 --- /dev/null +++ b/packages/api/tests/auth-menu.test.ts @@ -0,0 +1,102 @@ +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 { vi } from "vitest" + +const grokOidcAuthScope = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828" + +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-auth-menu-" + }) + ) + 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)) + ) + +describe("auth menu service", () => { + it.effect("counts only credential-bearing auth accounts", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const authRoot = path.join(projectsRoot, ".orch", "auth") + const envDir = path.join(projectsRoot, ".orch", "env") + + yield* _(fs.makeDirectory(path.join(authRoot, "claude", "empty"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "codex", "empty"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "gemini", "empty"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", "empty", ".grok"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "grok", "empty", ".grok", "user-settings.json"), "{\"sandboxMode\":\"off\"}\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "codex", ".image"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", ".image"), { recursive: true })) + + yield* _(fs.makeDirectory(path.join(authRoot, "claude", "live"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "claude", "live", ".oauth-token"), "claude-oauth\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "codex"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "codex", "auth.json"), "{\"tokens\":{\"account_id\":\"default\"}}\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "codex", "work"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "codex", "work", "auth.json"), "{\"tokens\":{\"account_id\":\"work\"}}\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "gemini", "live"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "gemini", "live", ".api-key"), "gemini-key\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", "live", ".grok"), { recursive: true })) + yield* _( + fs.writeFileString( + path.join(authRoot, "grok", "live", ".grok", "auth.json"), + `${JSON.stringify({ [grokOidcAuthScope]: { key: "xai-oauth" } })}\n` + ) + ) + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(envDir, "global.env"), "# docker-git env\n")) + + const service = yield* _( + withProjectsRoot( + projectsRoot, + Effect.gen(function*(_) { + yield* _(Effect.sync(() => vi.resetModules())) + return yield* _(Effect.promise(() => import("../src/services/auth-menu.js"))) + }) + ) + ) + const snapshot = yield* _(withProjectsRoot(projectsRoot, service.readAuthMenuSnapshot())) + + expect(snapshot.claudeAuthEntries).toBe(1) + expect(snapshot.codexAuthEntries).toBe(2) + expect(snapshot.geminiAuthEntries).toBe(1) + expect(snapshot.grokAuthEntries).toBe(1) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/api/tests/project-auth.test.ts b/packages/api/tests/project-auth.test.ts index 98355c22..13c7f612 100644 --- a/packages/api/tests/project-auth.test.ts +++ b/packages/api/tests/project-auth.test.ts @@ -127,6 +127,114 @@ const runGrokApiKeyConnectCase = (apiKey: string) => ) describe("project auth service", () => { + it.effect("counts only credential-bearing project auth accounts", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const authRoot = path.join(projectsRoot, ".orch", "auth") + const envDir = path.join(projectsRoot, ".orch", "env") + 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(path.join(authRoot, "claude", "empty"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "gemini", "empty"), { recursive: true })) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", "empty", ".grok"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "grok", "empty", ".grok", "user-settings.json"), "{\"sandboxMode\":\"off\"}\n")) + + yield* _(fs.makeDirectory(path.join(authRoot, "claude", "live"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "claude", "live", ".credentials.json"), "{}\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "gemini", "live", ".gemini"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "gemini", "live", ".gemini", "oauth-tokens.json"), "{}\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", "live"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "grok", "live", ".env"), "GROK_DEPLOYMENT_KEY='xai-deploy'\n")) + yield* _(fs.makeDirectory(projectDir, { recursive: true })) + yield* _(fs.makeDirectory(envDir, { recursive: true })) + 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 snapshot = yield* _(withProjectsRoot(projectsRoot, service.readProjectAuthSnapshot(project))) + + expect(snapshot.claudeAuthEntries).toBe(1) + expect(snapshot.geminiAuthEntries).toBe(1) + expect(snapshot.grokAuthEntries).toBe(1) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("requires real Gemini credentials before connecting a project", () => + 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 geminiDefaultAuth = path.join(projectsRoot, ".orch", "auth", "gemini", "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(geminiDefaultAuth, { recursive: true })) + yield* _(fs.makeDirectory(projectDir, { recursive: true })) + yield* _(fs.makeDirectory(envDir, { recursive: true })) + yield* _(fs.writeFileString(path.join(geminiDefaultAuth, ".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 emptyApiKeyFailure = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGeminiConnect", + label: "default" + }).pipe(Effect.flip) + ) + ) + + expect(emptyApiKeyFailure._tag).toBe("ApiBadRequestError") + expect(yield* _(fs.readFileString(envProjectPath))).not.toContain("GEMINI_AUTH_LABEL=default") + + yield* _(fs.remove(path.join(geminiDefaultAuth, ".api-key"))) + yield* _(fs.makeDirectory(path.join(geminiDefaultAuth, ".gemini"), { recursive: true })) + yield* _(fs.writeFileString(path.join(geminiDefaultAuth, ".gemini", "oauth_creds.json"), "{\"access_token\":\"test\"}\n")) + + const snapshot = yield* _( + withProjectsRoot( + projectsRoot, + service.runProjectAuthFlow(project, { + flow: "ProjectGeminiConnect", + label: "default" + }) + ) + ) + + expect(snapshot.activeGeminiLabel).toBe("default") + expect(snapshot.geminiAuthEntries).toBe(1) + expect(yield* _(fs.readFileString(envProjectPath))).toContain("GEMINI_AUTH_LABEL=default") + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("preserves Grok project API-key connect invariants", () => Effect.tryPromise({ catch: (error) => error, diff --git a/packages/app/src/docker-git/api-auth-codec.ts b/packages/app/src/docker-git/api-auth-codec.ts index b5eefe86..be920541 100644 --- a/packages/app/src/docker-git/api-auth-codec.ts +++ b/packages/app/src/docker-git/api-auth-codec.ts @@ -4,6 +4,7 @@ import type { AuthSnapshot, ProjectAuthSnapshot } from "./menu-types.js" type RawAuthSnapshot = { readonly globalEnvPath: string | null readonly claudeAuthPath: string | null + readonly codexAuthPath: string | null readonly geminiAuthPath: string | null readonly grokAuthPath: string | null readonly totalEntries: number | null @@ -11,6 +12,7 @@ type RawAuthSnapshot = { readonly gitTokenEntries: number | null readonly gitUserEntries: number | null readonly claudeAuthEntries: number | null + readonly codexAuthEntries: number | null readonly geminiAuthEntries: number | null readonly grokAuthEntries: number | null } @@ -59,6 +61,7 @@ const readAuthSnapshot = ( return { globalEnvPath: asString(snapshot["globalEnvPath"]), claudeAuthPath: asString(snapshot["claudeAuthPath"]), + codexAuthPath: asString(snapshot["codexAuthPath"]), geminiAuthPath: asString(snapshot["geminiAuthPath"]), grokAuthPath: asString(snapshot["grokAuthPath"]), totalEntries: readNumber(snapshot["totalEntries"]), @@ -66,19 +69,34 @@ const readAuthSnapshot = ( gitTokenEntries: readNumber(snapshot["gitTokenEntries"]), gitUserEntries: readNumber(snapshot["gitUserEntries"]), claudeAuthEntries: readNumber(snapshot["claudeAuthEntries"]), + codexAuthEntries: readNumber(snapshot["codexAuthEntries"]), geminiAuthEntries: readNumber(snapshot["geminiAuthEntries"]), grokAuthEntries: readNumber(snapshot["grokAuthEntries"]) } } const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | null => { - if (hasNullValue(Object.values(snapshot))) { + const requiredValues = [ + snapshot.globalEnvPath, + snapshot.claudeAuthPath, + snapshot.geminiAuthPath, + snapshot.grokAuthPath, + snapshot.totalEntries, + snapshot.githubTokenEntries, + snapshot.gitTokenEntries, + snapshot.gitUserEntries, + snapshot.claudeAuthEntries, + snapshot.geminiAuthEntries, + snapshot.grokAuthEntries + ] + if (hasNullValue(requiredValues)) { return null } return { globalEnvPath: stringOrEmpty(snapshot.globalEnvPath), claudeAuthPath: stringOrEmpty(snapshot.claudeAuthPath), + codexAuthPath: stringOrEmpty(snapshot.codexAuthPath), geminiAuthPath: stringOrEmpty(snapshot.geminiAuthPath), grokAuthPath: stringOrEmpty(snapshot.grokAuthPath), totalEntries: numberOrZero(snapshot.totalEntries), @@ -86,6 +104,7 @@ const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | n gitTokenEntries: numberOrZero(snapshot.gitTokenEntries), gitUserEntries: numberOrZero(snapshot.gitUserEntries), claudeAuthEntries: numberOrZero(snapshot.claudeAuthEntries), + codexAuthEntries: numberOrZero(snapshot.codexAuthEntries), geminiAuthEntries: numberOrZero(snapshot.geminiAuthEntries), grokAuthEntries: numberOrZero(snapshot.grokAuthEntries) } diff --git a/packages/app/src/docker-git/api-client-auth.ts b/packages/app/src/docker-git/api-client-auth.ts index b402be08..4503b7cd 100644 --- a/packages/app/src/docker-git/api-client-auth.ts +++ b/packages/app/src/docker-git/api-client-auth.ts @@ -6,7 +6,7 @@ import { authStreamMarkerExitCode, type AuthStreamMarkers, authStreamSucceeded, - authStreamVisibleLines, + codexLoginFailureMessage, codexLoginStreamMarkers, githubLoginFailureMessage, githubLoginStreamMarkers, @@ -34,27 +34,6 @@ import type { import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js" import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js" -const codexLoginFailureMessage = (output: string, exitCode: string | null): string => { - if (output.includes("429 Too Many Requests")) { - return "Codex device auth is rate-limited by OpenAI (429 Too Many Requests). Wait a few minutes and retry." - } - - const detailedLine = authStreamVisibleLines(output, codexLoginStreamMarkers) - .findLast((line) => line.toLowerCase().includes("error")) - if (detailedLine !== undefined) { - return detailedLine - } - - const lastLine = authStreamVisibleLines(output, codexLoginStreamMarkers).at(-1) - if (lastLine !== undefined) { - return lastLine - } - - return exitCode === null - ? "Codex login stream ended without a completion marker." - : `Codex login failed (${exitCode}).` -} - const streamFailure = ( method: "POST", path: string, diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts index 7814e114..3a3bf8a6 100644 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ b/packages/app/src/docker-git/menu-auth-effects.ts @@ -1,6 +1,6 @@ import { Effect, Match, pipe } from "effect" -import { createAuthTerminalSession, githubLogin } from "./api-client.js" +import { codexLogin, codexLogout, createAuthTerminalSession, githubLogin } from "./api-client.js" import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js" import { terminalAuthTitle } from "./menu-auth-shared.js" import type { MenuError } from "./menu-errors.js" @@ -61,6 +61,18 @@ export const resolveAuthPromptEffect = ( scopes: null, envGlobalPath: view.snapshot.globalEnvPath }).pipe(Effect.asVoid)), + Match.when("CodexOauth", () => + codexLogin({ + _tag: "AuthCodexLogin", + label: labelOption, + codexAuthPath: view.snapshot.codexAuthPath + })), + Match.when("CodexLogout", () => + codexLogout({ + _tag: "AuthCodexLogout", + label: labelOption, + codexAuthPath: view.snapshot.codexAuthPath + })), Match.when("ClaudeOauth", () => resolveTerminalAuthEffect("ClaudeOauth", labelOption)), Match.when("ClaudeLogout", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GeminiOauth", () => resolveTerminalAuthEffect("GeminiOauth", labelOption)), diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts index cdd3a4bc..80ae8b43 100644 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-auth-helpers.ts @@ -3,10 +3,44 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" -export const countAuthAccountDirectories = ( +type HasCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +) => Effect.Effect + +const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) + +const hasFileAtPath = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(filePath)) + if (!exists) { + return false + } + const info = yield* _(fs.stat(filePath)) + return info.type === "File" + }) + +const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, filePath)) + if (!hasFile) { + return false + } + const content = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) + return content.trim().length > 0 + }) + +export const countAuthCredentialAccounts = ( fs: FileSystem.FileSystem, path: Path.Path, - root: string + root: string, + hasCredentials: HasCredentials ): Effect.Effect => Effect.gen(function*(_) { const exists = yield* _(fs.exists(root)) @@ -16,12 +50,52 @@ export const countAuthAccountDirectories = ( const entries = yield* _(fs.readDirectory(root)) let count = 0 for (const entry of entries) { - if (entry === ".image") { + if (ignoredAuthAccountEntries.has(entry)) { + continue + } + const accountPath = path.join(root, entry) + const info = yield* _(fs.stat(accountPath)) + if (info.type !== "Directory") { + continue + } + const connected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (connected) { + count += 1 + } + } + return count + }) + +export const hasCodexAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasNonEmptyFile(fs, `${accountPath}/auth.json`) + +export const countCodexCredentialAccounts = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const exists = yield* _(fs.exists(root)) + if (!exists) { + return 0 + } + + let count = yield* _(hasCodexAccountCredentials(fs, root), Effect.map((connected) => connected ? 1 : 0)) + const entries = yield* _(fs.readDirectory(root)) + for (const entry of entries) { + if (ignoredAuthAccountEntries.has(entry)) { + continue + } + const accountPath = path.join(root, entry) + const info = yield* _(fs.stat(accountPath)) + if (info.type !== "Directory") { continue } - const fullPath = path.join(root, entry) - const info = yield* _(fs.stat(fullPath)) - if (info.type === "Directory") { + const connected = yield* _(hasCodexAccountCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) + if (connected) { count += 1 } } diff --git a/packages/app/src/docker-git/menu-auth-shared.ts b/packages/app/src/docker-git/menu-auth-shared.ts index 9f42bfb5..0b928596 100644 --- a/packages/app/src/docker-git/menu-auth-shared.ts +++ b/packages/app/src/docker-git/menu-auth-shared.ts @@ -4,7 +4,10 @@ import type { AuthFlow } from "./menu-types.js" export type AuthMenuAction = AuthFlow | "Refresh" | "Back" -export type AuthEnvFlow = Exclude +export type AuthEnvFlow = Exclude< + AuthFlow, + "GithubOauth" | "CodexOauth" | "CodexLogout" | "ClaudeOauth" | "GeminiOauth" | "GrokOauth" +> export type TerminalAuthFlow = Extract export type AuthPromptStep = { @@ -24,6 +27,8 @@ const authMenuItems: ReadonlyArray = [ { action: "GithubRemove", label: "GitHub: remove token" }, { action: "GitSet", label: "Git: add/update credentials" }, { action: "GitRemove", label: "Git: remove credentials" }, + { action: "CodexOauth", label: "Codex CLI: login via OAuth (OpenAI account)" }, + { action: "CodexLogout", label: "Codex CLI: logout (clear credentials)" }, { action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" }, { action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" }, { action: "GeminiOauth", label: "Gemini CLI: login via OAuth (Google account)" }, @@ -51,6 +56,12 @@ const flowSteps: Readonly>> = { GitRemove: [ { key: "label", label: "Label to remove (empty = default)", required: false, secret: false } ], + CodexOauth: [ + { key: "label", label: "Label (empty = default)", required: false, secret: false } + ], + CodexLogout: [ + { key: "label", label: "Label to logout (empty = default)", required: false, secret: false } + ], ClaudeOauth: [ { key: "label", label: "Label (empty = default)", required: false, secret: false } ], @@ -85,6 +96,8 @@ export const successMessage = (flow: AuthFlow, label: string): string => Match.when("GithubRemove", () => `Removed GitHub token (${label}).`), Match.when("GitSet", () => `Saved Git credentials (${label}).`), Match.when("GitRemove", () => `Removed Git credentials (${label}).`), + Match.when("CodexOauth", () => `Saved Codex login (${label}).`), + Match.when("CodexLogout", () => `Logged out Codex CLI (${label}).`), Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`), Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`), Match.when("GeminiOauth", () => `Saved Gemini CLI OAuth login (${label}).`), @@ -102,6 +115,8 @@ export const authViewTitle = (flow: AuthFlow): string => Match.when("GithubRemove", () => "GitHub remove"), Match.when("GitSet", () => "Git credentials"), Match.when("GitRemove", () => "Git remove"), + Match.when("CodexOauth", () => "Codex CLI OAuth"), + Match.when("CodexLogout", () => "Codex CLI logout"), Match.when("ClaudeOauth", () => "Claude Code OAuth"), Match.when("ClaudeLogout", () => "Claude Code logout"), Match.when("GeminiOauth", () => "Gemini CLI OAuth"), diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts index 86ef6d33..a8a78dcc 100644 --- a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts +++ b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts @@ -3,10 +3,14 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect, pipe } from "effect" -import { countAuthAccountDirectories } from "./menu-auth-helpers.js" +import { countAuthCredentialAccounts, countCodexCredentialAccounts } from "./menu-auth-helpers.js" +import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js" +import { hasGeminiAccountCredentials } from "./menu-project-auth-gemini.js" +import { hasGrokAccountCredentials } from "./menu-project-auth-grok.js" export type AuthAccountCounts = { readonly claudeAuthEntries: number + readonly codexAuthEntries: number readonly geminiAuthEntries: number readonly grokAuthEntries: number } @@ -15,13 +19,15 @@ export const countAuthAccountEntries = ( fs: FileSystem.FileSystem, path: Path.Path, claudeAuthPath: string, + codexAuthPath: string, geminiAuthPath: string, grokAuthPath: string ): Effect.Effect => pipe( Effect.all({ - claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath), - grokAuthEntries: countAuthAccountDirectories(fs, path, grokAuthPath) + claudeAuthEntries: countAuthCredentialAccounts(fs, path, claudeAuthPath, hasClaudeAccountCredentials), + codexAuthEntries: countCodexCredentialAccounts(fs, path, codexAuthPath), + geminiAuthEntries: countAuthCredentialAccounts(fs, path, geminiAuthPath, hasGeminiAccountCredentials), + grokAuthEntries: countAuthCredentialAccounts(fs, path, grokAuthPath, hasGrokAccountCredentials) }) ) diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts index 6d3eca3d..c4444846 100644 --- a/packages/app/src/docker-git/menu-auth.ts +++ b/packages/app/src/docker-git/menu-auth.ts @@ -99,8 +99,8 @@ const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => { const label = defaultLabel(nextValues["label"] ?? "") const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues) runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, { - suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" || - view.flow === "GeminiOauth" || view.flow === "GrokOauth" + suspendTui: view.flow === "GithubOauth" || view.flow === "CodexOauth" || view.flow === "ClaudeOauth" || + view.flow === "ClaudeLogout" || view.flow === "GeminiOauth" || view.flow === "GrokOauth" }) } ) diff --git a/packages/app/src/docker-git/menu-project-auth-gemini.ts b/packages/app/src/docker-git/menu-project-auth-gemini.ts index c16d93f2..d4131ef0 100644 --- a/packages/app/src/docker-git/menu-project-auth-gemini.ts +++ b/packages/app/src/docker-git/menu-project-auth-gemini.ts @@ -2,7 +2,7 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import { Effect } from "effect" -import { hasFileAtPath } from "./menu-project-auth-helpers.js" +import { hasAccountCredentials, hasFileAtPath } from "./menu-project-auth-helpers.js" // CHANGE: add Gemini CLI account credentials check for project auth // WHY: enable Gemini CLI authentication verification at project level (API key or OAuth) @@ -18,42 +18,12 @@ import { hasFileAtPath } from "./menu-project-auth-helpers.js" const apiKeyFileName = ".api-key" const envFileName = ".env" const geminiCredentialsDir = ".gemini" - -const hasNonEmptyApiKey = ( - fs: FileSystem.FileSystem, - apiKeyPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, apiKeyPath)) - if (!hasFile) { - return false - } - const keyValue = yield* _(fs.readFileString(apiKeyPath), Effect.orElseSucceed(() => "")) - return keyValue.trim().length > 0 - }) - -const hasApiKeyInEnvFile = ( - fs: FileSystem.FileSystem, - envFilePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) - if (!hasFile) { - return false - } - const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) - const lines = envContent.split("\n") - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith("GEMINI_API_KEY=")) { - const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() - if (value.length > 0) { - return true - } - } - } - return false - }) +const geminiEnvApiKeyNames: ReadonlyArray = ["GEMINI_API_KEY"] +const geminiCredentialSpec = { + apiKeyFileName, + envFileName, + envKeys: geminiEnvApiKeyNames +} // CHANGE: check for OAuth credentials in .gemini directory // WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow @@ -64,6 +34,7 @@ const hasApiKeyInEnvFile = ( // INVARIANT: checks for existence of OAuth credential files // COMPLEXITY: O(n) where n = number of possible credential files const geminiOauthCredentialFiles: ReadonlyArray = [ + "oauth_creds.json", "oauth-tokens.json", "credentials.json", "application_default_credentials.json" @@ -86,31 +57,16 @@ const checkAnyFileExists = ( const hasOauthCredentials = ( fs: FileSystem.FileSystem, accountPath: string -): Effect.Effect => { - const credentialsDir = `${accountPath}/${geminiCredentialsDir}` - return hasFileAtPath(fs, credentialsDir).pipe( - Effect.flatMap((dirExists) => - dirExists ? checkAnyFileExists(fs, credentialsDir, geminiOauthCredentialFiles) : Effect.succeed(false) - ) - ) -} +): Effect.Effect => + checkAnyFileExists(fs, `${accountPath}/${geminiCredentialsDir}`, geminiOauthCredentialFiles) export const hasGeminiAccountCredentials = ( fs: FileSystem.FileSystem, accountPath: string ): Effect.Effect => - hasNonEmptyApiKey(fs, `${accountPath}/${apiKeyFileName}`).pipe( - Effect.flatMap((hasApiKey) => { - if (hasApiKey) { - return Effect.succeed(true) - } - return hasApiKeyInEnvFile(fs, `${accountPath}/${envFileName}`).pipe( - Effect.flatMap((hasEnvApiKey) => { - if (hasEnvApiKey) { - return Effect.succeed(true) - } - return hasOauthCredentials(fs, accountPath) - }) - ) - }) + hasAccountCredentials( + fs, + accountPath, + geminiCredentialSpec, + hasOauthCredentials(fs, accountPath) ) diff --git a/packages/app/src/docker-git/menu-project-auth-grok-credential-text.ts b/packages/app/src/docker-git/menu-project-auth-grok-credential-text.ts new file mode 100644 index 00000000..716c13ac --- /dev/null +++ b/packages/app/src/docker-git/menu-project-auth-grok-credential-text.ts @@ -0,0 +1,77 @@ +/* jscpd:ignore-start */ +import * as ParseResult from "@effect/schema/ParseResult" +import * as Schema from "@effect/schema/Schema" +import { Either } from "effect" + +type JsonPrimitive = boolean | number | string | null +type JsonValue = JsonPrimitive | JsonRecord | ReadonlyArray +type JsonRecord = Readonly<{ [key: string]: JsonValue }> + +const JsonValueSchema: Schema.Schema = Schema.suspend(() => + Schema.Union( + Schema.Null, + Schema.Boolean, + Schema.String, + Schema.JsonNumber, + Schema.Array(JsonValueSchema), + Schema.Record({ key: Schema.String, value: JsonValueSchema }) + ) +) + +const JsonRecordSchema: Schema.Schema = Schema.Record({ + key: Schema.String, + value: JsonValueSchema +}) + +const JsonRecordFromStringSchema = Schema.parseJson(JsonRecordSchema) +const officialGrokAuthScopes: ReadonlyArray = [ + "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828", + "https://accounts.x.ai/sign-in" +] +const grokUserSettingsCredentialKeys: ReadonlyArray = [ + "apiKey", + "accessToken", + "access_token", + "authToken", + "refreshToken", + "refresh_token" +] +const grokOauthCredentialKeys: ReadonlyArray = [...grokUserSettingsCredentialKeys, "token"] +const grokUserSettingsFallbackCredentialMarkers: ReadonlyArray = [ + /"(?:apiKey|accessToken|access_token|authToken|refreshToken|refresh_token)"\s*:\s*"[^"]+"/u +] + +const parseJsonRecordOrNull = (text: string): JsonRecord | null => + Either.match(ParseResult.decodeUnknownEither(JsonRecordFromStringSchema)(text), { + onLeft: () => null, + onRight: (record) => record + }) + +const isJsonRecord = (value: JsonValue | undefined): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value) + +const hasNonEmptyStringProperty = (record: JsonRecord, key: string): boolean => + typeof record[key] === "string" && record[key].trim().length > 0 + +export const hasGrokUserSettingsCredentialText = (settingsText: string): boolean => { + const parsed = parseJsonRecordOrNull(settingsText) + if (parsed !== null) { + if (grokUserSettingsCredentialKeys.some((key) => hasNonEmptyStringProperty(parsed, key))) { + return true + } + const oauth = parsed["oauth"] + return isJsonRecord(oauth) && grokOauthCredentialKeys.some((key) => hasNonEmptyStringProperty(oauth, key)) + } + + return grokUserSettingsFallbackCredentialMarkers.some((marker) => marker.test(settingsText)) +} + +export const hasGrokAuthJsonCredentialText = (authJsonText: string): boolean => { + const parsed = parseJsonRecordOrNull(authJsonText) + return parsed !== null && + officialGrokAuthScopes.some((scope) => { + const scopedCredentials = parsed[scope] + return isJsonRecord(scopedCredentials) && hasNonEmptyStringProperty(scopedCredentials, "key") + }) +} +/* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/menu-project-auth-grok.ts b/packages/app/src/docker-git/menu-project-auth-grok.ts new file mode 100644 index 00000000..7546a72f --- /dev/null +++ b/packages/app/src/docker-git/menu-project-auth-grok.ts @@ -0,0 +1,61 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +import { + hasGrokAuthJsonCredentialText, + hasGrokUserSettingsCredentialText +} from "./menu-project-auth-grok-credential-text.js" +import { hasAccountCredentials, hasFileAtPath } from "./menu-project-auth-helpers.js" + +const grokEnvApiKeyNames: ReadonlyArray = ["GROK_DEPLOYMENT_KEY", "GROK_API_KEY", "XAI_API_KEY"] +const grokApiKeyFileName = ".api-key" +const grokEnvFileName = ".env" +const grokCredentialSpec = { + apiKeyFileName: grokApiKeyFileName, + envFileName: grokEnvFileName, + envKeys: grokEnvApiKeyNames +} + +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 hasGrokUserSettingsCredentials = ( + fs: FileSystem.FileSystem, + userSettingsPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const hasFile = yield* _(hasFileAtPath(fs, userSettingsPath)) + if (!hasFile) { + return false + } + const settingsText = yield* _(fs.readFileString(userSettingsPath), Effect.orElseSucceed(() => "")) + return hasGrokUserSettingsCredentialText(settingsText) + }) + +export const hasGrokAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + hasAccountCredentials( + fs, + accountPath, + grokCredentialSpec, + hasGrokAuthJsonCredentials(fs, `${accountPath}/.grok/auth.json`).pipe( + Effect.flatMap((hasAuthJson) => + hasAuthJson + ? Effect.succeed(true) + : hasGrokUserSettingsCredentials(fs, `${accountPath}/.grok/user-settings.json`) + ) + ) + ) diff --git a/packages/app/src/docker-git/menu-project-auth-helpers.ts b/packages/app/src/docker-git/menu-project-auth-helpers.ts index 97ae6a97..493ba181 100644 --- a/packages/app/src/docker-git/menu-project-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-project-auth-helpers.ts @@ -2,15 +2,85 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import { Effect } from "effect" +type AccountCredentialSpec = { + readonly apiKeyFileName: string + readonly envFileName: string + readonly envKeys: ReadonlyArray +} + export const hasFileAtPath = ( fs: FileSystem.FileSystem, filePath: string +): Effect.Effect => + fs.stat(filePath).pipe( + Effect.map((info) => info.type === "File"), + Effect.catchAll(() => Effect.succeed(false)) + ) + +export const hasNonEmptyFile = ( + fs: FileSystem.FileSystem, + filePath: string +): Effect.Effect => + fs.readFileString(filePath).pipe( + Effect.map((content) => content.trim().length > 0), + Effect.catchAll(() => Effect.succeed(false)) + ) + +export const hasNonEmptyEnvValue = ( + fs: FileSystem.FileSystem, + envFilePath: string, + keys: ReadonlyArray ): Effect.Effect => Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { + const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) + if (!hasFile) { return false } - const info = yield* _(fs.stat(filePath)) - return info.type === "File" + const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) + for (const line of envContent.split("\n")) { + const trimmed = line.trim() + for (const key of keys) { + const prefix = `${key}=` + if (!trimmed.startsWith(prefix)) { + continue + } + const value = trimmed.slice(prefix.length).replaceAll(/^['"]|['"]$/g, "").trim() + if (value.length > 0) { + return true + } + } + } + return false }) + +export const hasApiKeyOrEnvCredentials = ( + fs: FileSystem.FileSystem, + apiKeyPath: string, + envFilePath: string, + envKeys: ReadonlyArray, + fallback: Effect.Effect +): Effect.Effect => + hasNonEmptyFile(fs, apiKeyPath).pipe( + Effect.flatMap((hasApiKey) => { + if (hasApiKey) { + return Effect.succeed(true) + } + return hasNonEmptyEnvValue(fs, envFilePath, envKeys).pipe( + Effect.flatMap((hasEnvValue) => hasEnvValue ? Effect.succeed(true) : fallback) + ) + }) + ) + +export const hasAccountCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string, + spec: AccountCredentialSpec, + fallback: Effect.Effect +): Effect.Effect => + hasApiKeyOrEnvCredentials( + fs, + `${accountPath}/${spec.apiKeyFileName}`, + `${accountPath}/${spec.envFileName}`, + spec.envKeys, + fallback + ) diff --git a/packages/app/src/docker-git/menu-render-auth.ts b/packages/app/src/docker-git/menu-render-auth.ts index 7b85c366..1dafffe5 100644 --- a/packages/app/src/docker-git/menu-render-auth.ts +++ b/packages/app/src/docker-git/menu-render-auth.ts @@ -18,11 +18,13 @@ const renderCountLine = (title: string, count: number): string => `${title}: ${c const oauthPromptFlows: ReadonlySet = new Set([ "GithubOauth", + "CodexOauth", "ClaudeOauth", "GeminiOauth", "GrokOauth" ]) +const codexPromptFlows: ReadonlySet = new Set(["CodexOauth", "CodexLogout"]) const claudePromptFlows: ReadonlySet = new Set(["ClaudeOauth", "ClaudeLogout"]) const geminiPromptFlows: ReadonlySet = new Set(["GeminiOauth", "GeminiApiKey", "GeminiLogout"]) const grokPromptFlows: ReadonlySet = new Set(["GrokOauth", "GrokApiKey", "GrokLogout"]) @@ -39,6 +41,7 @@ const authPromptHelpLine = (flow: AuthPromptFlow): string => { const authPromptHeaderPaths = (view: AuthPromptView): ReadonlyArray => [ `Global env: ${view.snapshot.globalEnvPath}`, + ...(codexPromptFlows.has(view.flow) ? [`Codex auth: ${view.snapshot.codexAuthPath}`] : []), ...(claudePromptFlows.has(view.flow) ? [`Claude auth: ${view.snapshot.claudeAuthPath}`] : []), ...(geminiPromptFlows.has(view.flow) ? [`Gemini auth: ${view.snapshot.geminiAuthPath}`] : []), ...(grokPromptFlows.has(view.flow) ? [`Grok auth: ${view.snapshot.grokAuthPath}`] : []) @@ -55,6 +58,7 @@ export const renderAuthMenu = ( "docker-git / Auth profiles", [ el(Text, null, `Global env: ${snapshot.globalEnvPath}`), + el(Text, null, `Codex auth: ${snapshot.codexAuthPath}`), el(Text, null, `Claude auth: ${snapshot.claudeAuthPath}`), el(Text, null, `Gemini auth: ${snapshot.geminiAuthPath}`), el(Text, null, `Grok auth: ${snapshot.grokAuthPath}`), @@ -62,6 +66,7 @@ export const renderAuthMenu = ( el(Text, { fg: "gray" }, renderCountLine("GitHub tokens", snapshot.githubTokenEntries)), el(Text, { fg: "gray" }, renderCountLine("Git tokens", snapshot.gitTokenEntries)), el(Text, { fg: "gray" }, renderCountLine("Git users", snapshot.gitUserEntries)), + el(Text, { fg: "gray" }, renderCountLine("Codex logins", snapshot.codexAuthEntries)), el(Text, { fg: "gray" }, renderCountLine("Claude logins", snapshot.claudeAuthEntries)), el(Text, { fg: "gray" }, renderCountLine("Gemini logins", snapshot.geminiAuthEntries)), el(Text, { fg: "gray" }, renderCountLine("Grok logins", snapshot.grokAuthEntries)), diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 7936cd15..72d98164 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -85,6 +85,8 @@ export type AuthFlow = | "GithubRemove" | "GitSet" | "GitRemove" + | "CodexOauth" + | "CodexLogout" | "ClaudeOauth" | "ClaudeLogout" | "GeminiOauth" @@ -97,6 +99,7 @@ export type AuthFlow = export interface AuthSnapshot { readonly globalEnvPath: string readonly claudeAuthPath: string + readonly codexAuthPath: string readonly geminiAuthPath: string readonly grokAuthPath: string readonly totalEntries: number @@ -104,6 +107,7 @@ export interface AuthSnapshot { readonly gitTokenEntries: number readonly gitUserEntries: number readonly claudeAuthEntries: number + readonly codexAuthEntries: number readonly geminiAuthEntries: number readonly grokAuthEntries: number } diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 1892bcf1..f3f97980 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -21,7 +21,7 @@ import { import { type ControllerRuntime, ensureControllerReady } from "./controller.js" import type { Command } from "./frontend-lib/core/domain.js" import type { ApiRequestError, CliError } from "./host-errors.js" -import { terminalAuthTitle } from "./menu-auth-shared.js" +import { terminalAuthTitle, type TerminalAuthFlow } from "./menu-auth-shared.js" import { attachTerminalSession } from "./terminal-session-client.js" type OperationalCommand = Exclude @@ -37,6 +37,8 @@ export type RoutedAuthCommand = Extract< | "AuthGitlabLogin" | "AuthGitlabStatus" | "AuthGitlabLogout" + | "AuthClaudeLogin" + | "AuthGeminiLogin" | "AuthGrokLogin" | "AuthGrokStatus" | "AuthGrokLogout" @@ -53,7 +55,7 @@ const withControllerReady = ( const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) -const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError => ({ +const missingAuthTerminalSessionError = (provider: TerminalAuthFlow): ApiRequestError => ({ _tag: "ApiRequestError", method: "POST", path: "/auth/terminal-sessions", @@ -68,6 +70,8 @@ const routedAuthTags: Readonly> = { AuthGithubLogin: true, AuthGithubLogout: true, AuthGithubStatus: true, + AuthClaudeLogin: true, + AuthGeminiLogin: true, AuthGrokLogin: true, AuthGrokLogout: true, AuthGitlabLogin: true, @@ -109,34 +113,42 @@ const handleCodexLoginCommand = ( command: Extract ) => withControllerReady(codexLogin(command)) -/** - * Attaches the Grok OAuth terminal session created by the controller. - * - * @pure false - * @effect terminal websocket attachment through `attachTerminalSession` - * @invariant null controller sessions fail with a typed ApiRequestError - * @precondition controller response has already been decoded as ApiTerminalSession | null - * @postcondition non-null sessions are attached through the auth terminal websocket path - * @complexity O(1) before terminal IO - * @throws Never; errors are represented in the Effect error channel as CliError - */ -const attachGrokAuthTerminalSession = ( +const attachAuthTerminalSession = ( + flow: TerminalAuthFlow, session: ApiTerminalSession | null ): Effect.Effect => session === null - ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) + ? Effect.fail(missingAuthTerminalSessionError(flow)) : attachTerminalSession({ - header: terminalAuthTitle("GrokOauth"), + header: terminalAuthTitle(flow), session, websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` }) +const handleClaudeLoginCommand = ( + command: Extract +) => + withControllerReady( + createAuthTerminalSession("ClaudeOauth", command.label).pipe( + Effect.flatMap((session) => attachAuthTerminalSession("ClaudeOauth", session)) + ) + ) + +const handleGeminiLoginCommand = ( + command: Extract +) => + withControllerReady( + createAuthTerminalSession("GeminiOauth", command.label).pipe( + Effect.flatMap((session) => attachAuthTerminalSession("GeminiOauth", session)) + ) + ) + const handleGrokLoginCommand = ( command: Extract ) => withControllerReady( createAuthTerminalSession("GrokOauth", command.label).pipe( - Effect.flatMap((session) => attachGrokAuthTerminalSession(session)) + Effect.flatMap((session) => attachAuthTerminalSession("GrokOauth", session)) ) ) @@ -176,6 +188,8 @@ export const dispatchRoutedAuthCommand = ( Match.when({ _tag: "AuthGitlabLogin" }, handleGitlabLoginCommand), Match.when({ _tag: "AuthGitlabStatus" }, handleGitlabStatusCommand), Match.when({ _tag: "AuthGitlabLogout" }, handleGitlabLogoutCommand), + Match.when({ _tag: "AuthClaudeLogin" }, handleClaudeLoginCommand), + Match.when({ _tag: "AuthGeminiLogin" }, handleGeminiLoginCommand), Match.when({ _tag: "AuthGrokLogin" }, handleGrokLoginCommand), Match.when({ _tag: "AuthGrokStatus" }, handleGrokStatusCommand), Match.when({ _tag: "AuthGrokLogout" }, handleGrokLogoutCommand), diff --git a/packages/app/src/docker-git/program-unsupported.ts b/packages/app/src/docker-git/program-unsupported.ts index b7fdacc8..bb33082f 100644 --- a/packages/app/src/docker-git/program-unsupported.ts +++ b/packages/app/src/docker-git/program-unsupported.ts @@ -5,15 +5,10 @@ export type UnsupportedOperationalCommandTag = | "ScrapImport" | "McpPlaywrightUp" | "Apply" - | "AuthClaudeLogin" | "AuthClaudeStatus" | "AuthClaudeLogout" - | "AuthGeminiLogin" | "AuthGeminiStatus" | "AuthGeminiLogout" - | "AuthGrokLogin" - | "AuthGrokStatus" - | "AuthGrokLogout" export const unsupportedOperationalCommands: Record< UnsupportedOperationalCommandTag, @@ -31,40 +26,20 @@ export const unsupportedOperationalCommands: Record< command: "Apply", message: "Command Apply is not available in API-only host mode." }, - AuthClaudeLogin: { - command: "auth claude login", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." - }, AuthClaudeStatus: { command: "auth claude status", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + message: "Claude status is not routed through the controller in host API mode." }, AuthClaudeLogout: { command: "auth claude logout", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." - }, - AuthGeminiLogin: { - command: "auth gemini login", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + message: "Claude logout is not routed through the controller in host API mode." }, AuthGeminiStatus: { command: "auth gemini status", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + message: "Gemini status is not routed through the controller in host API mode." }, AuthGeminiLogout: { command: "auth gemini logout", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." - }, - AuthGrokLogin: { - command: "auth grok login", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." - }, - AuthGrokStatus: { - command: "auth grok status", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." - }, - AuthGrokLogout: { - command: "auth grok logout", - message: "Only GitHub, GitLab, and Codex auth are routed through the controller in host API mode." + message: "Gemini logout is not routed through the controller in host API mode." } } diff --git a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts index d4dcdce2..c385058d 100644 --- a/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/app/src/lib/core/templates-entrypoint/nested-docker-git.ts @@ -6,6 +6,7 @@ const entrypointDockerGitBootstrapTemplate = String DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git" DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude" +DOCKER_GIT_GROK_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/grok" DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" @@ -16,10 +17,11 @@ BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_SOURCE_ROOT/authorized-keys/__AUTHORIZED_KEYS_BA BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/codex" BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex" BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/claude" +BOOTSTRAP_GROK_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/grok" BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_SOURCE_ROOT/env-global/__ENV_GLOBAL_BASENAME__" BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_SOURCE_ROOT/env-project/__ENV_PROJECT_BASENAME__" -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" sync_file_if_present() { local source="$1" @@ -192,6 +194,7 @@ copy_if_distinct_file() { sync_dir_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" sync_dir_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" +sync_dir_entries "$BOOTSTRAP_GROK_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" if [[ -n "$GH_TOKEN" ]]; then upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" diff --git a/packages/app/src/lib/usecases/actions/create-project.ts b/packages/app/src/lib/usecases/actions/create-project.ts index b819b9ac..845925b8 100644 --- a/packages/app/src/lib/usecases/actions/create-project.ts +++ b/packages/app/src/lib/usecases/actions/create-project.ts @@ -7,6 +7,7 @@ import { Effect } from "effect" import type { CreateCommand, ParseError } from "../../core/domain.js" import { deriveRepoPathParts } from "../../core/domain.js" +import { defaultTemplateConfig } from "../../core/template-defaults.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" import type { AgentFailedError, @@ -77,6 +78,11 @@ const makeCreateContext = (path: Path.Path, baseDir: string): CreateContext => { return { baseDir, resolveRootPath } } +const resolveConfigGrokAuthPath = (config: CreateCommand["config"]): string => { + const legacyConfig: { readonly grokAuthPath?: string } = config + return legacyConfig.grokAuthPath ?? defaultTemplateConfig.grokAuthPath +} + const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): CreateCommand["config"] => ({ ...command.config, dockerGitPath: ctx.resolveRootPath(command.config.dockerGitPath), @@ -84,7 +90,8 @@ const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): Create envGlobalPath: ctx.resolveRootPath(command.config.envGlobalPath), envProjectPath: ctx.resolveRootPath(command.config.envProjectPath), codexAuthPath: ctx.resolveRootPath(command.config.codexAuthPath), - codexSharedAuthPath: ctx.resolveRootPath(command.config.codexSharedAuthPath) + codexSharedAuthPath: ctx.resolveRootPath(command.config.codexSharedAuthPath), + grokAuthPath: ctx.resolveRootPath(resolveConfigGrokAuthPath(command.config)) }) const resolveCreateConfig = ( diff --git a/packages/app/src/lib/usecases/actions/paths.ts b/packages/app/src/lib/usecases/actions/paths.ts index d04c389b..d342c738 100644 --- a/packages/app/src/lib/usecases/actions/paths.ts +++ b/packages/app/src/lib/usecases/actions/paths.ts @@ -1,12 +1,18 @@ /* jscpd:ignore-start */ import type * as Path from "@effect/platform/Path" import type { CreateCommand } from "../../core/domain.js" +import { defaultTemplateConfig } from "../../core/template-defaults.js" export const resolvePathFromBase = (path: Path.Path, baseDir: string, targetPath: string): string => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) const toPosixPath = (value: string): string => value.replaceAll("\\", "/") +const resolveConfigGrokAuthPath = (config: CreateCommand["config"]): string => { + const legacyConfig: { readonly grokAuthPath?: string } = config + return legacyConfig.grokAuthPath ?? defaultTemplateConfig.grokAuthPath +} + export const resolveDockerGitRootRelativePath = ( path: Path.Path, projectsRoot: string, @@ -42,6 +48,7 @@ export const buildProjectConfigs = ( // docker-compose resolves relative host paths from the project directory (where docker-compose.yml lives). // To keep generated projects portable across host OSes, we avoid embedding absolute host paths in templates. const relativeFromOutDir = (absolutePath: string): string => toPosixPath(path.relative(resolvedOutDir, absolutePath)) + const grokAuthPath = resolveConfigGrokAuthPath(resolvedConfig) const globalConfig = { ...resolvedConfig, @@ -50,7 +57,8 @@ export const buildProjectConfigs = ( envGlobalPath: resolvePathFromBase(path, baseDir, resolvedConfig.envGlobalPath), envProjectPath: resolvePathFromBase(path, baseDir, resolvedConfig.envProjectPath), codexAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexAuthPath), - codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath) + codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath), + grokAuthPath: resolvePathFromBase(path, baseDir, grokAuthPath) } const projectConfig = { ...resolvedConfig, @@ -63,7 +71,9 @@ export const buildProjectConfigs = ( // Project-local Codex state (sessions/logs/etc) is kept under .orch. codexAuthPath: "./.orch/auth/codex", // Keep the global auth source path so runtime can seed the shared Docker volume when containers start. - codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) + codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath), + // Keep the global Grok source path so runtime bootstrap can seed selected Grok labels. + grokAuthPath: relativeFromOutDir(globalConfig.grokAuthPath) } return { globalConfig, projectConfig } } diff --git a/packages/app/src/lib/usecases/actions/prepare-files.ts b/packages/app/src/lib/usecases/actions/prepare-files.ts index d5ef20ce..6abcc4e4 100644 --- a/packages/app/src/lib/usecases/actions/prepare-files.ts +++ b/packages/app/src/lib/usecases/actions/prepare-files.ts @@ -323,6 +323,8 @@ export const migrateProjectOrchLayout = ( envProjectPath: globalConfig.envProjectPath, codexAuthPath: globalConfig.codexAuthPath, ghAuthPath: resolveRootPath(".docker-git/.orch/auth/gh"), - claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude") + claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude"), + geminiAuthPath: resolveRootPath(".docker-git/.orch/auth/gemini"), + grokAuthPath: resolveRootPath(".docker-git/.orch/auth/grok") }) /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/usecases/auth-grok-oauth.ts b/packages/app/src/lib/usecases/auth-grok-oauth.ts index 34d62984..1005c1aa 100644 --- a/packages/app/src/lib/usecases/auth-grok-oauth.ts +++ b/packages/app/src/lib/usecases/auth-grok-oauth.ts @@ -42,15 +42,15 @@ const buildDockerGrokAuthSpec = ( }) /** - * Builds the Docker CLI argument vector for the official Grok device-code login flow. + * Builds the Docker CLI argument vector for the official Grok OAuth/browser login flow. * * @param spec Docker auth container paths, image, working directory, and environment bindings. - * @returns Immutable Docker argument vector ending with `grok login --device-auth`. + * @returns Immutable Docker argument vector ending with `grok login`. * @pure true * @effect none; CORE argument builder only transforms immutable input data. * @invariant every non-empty environment binding is emitted as an adjacent `-e` argument pair. * @precondition spec.hostPath and spec.containerPath identify the selected Grok auth account directory. - * @postcondition returned args execute the official headless Grok login mode documented by xAI. + * @postcondition returned args execute the same official interactive Grok login command as the CLI route. * @complexity O(n) time / O(n) space, where n is spec.env.length. * @throws Never - invalid process execution is represented by callers through typed Effect errors. */ @@ -74,7 +74,7 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray } base.push("-e", trimmed) } - return [...base, spec.image, "grok", "login", "--device-auth"] + return [...base, spec.image, "grok", "login"] } const printOauthInstructions = (): Effect.Effect => @@ -120,7 +120,7 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st ) /** - * Runs the Grok OAuth device login inside the docker-git auth container. + * Runs the Grok OAuth/browser login inside the docker-git auth container. * * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. diff --git a/packages/app/src/lib/usecases/auth-sync.ts b/packages/app/src/lib/usecases/auth-sync.ts index fd46e3d5..f0375f32 100644 --- a/packages/app/src/lib/usecases/auth-sync.ts +++ b/packages/app/src/lib/usecases/auth-sync.ts @@ -212,6 +212,8 @@ export const migrateLegacyOrchLayout = ( const legacyCodex = path.join(legacyRoot, "auth", "codex") const legacyGh = path.join(legacyRoot, "auth", "gh") const legacyClaude = path.join(legacyRoot, "auth", "claude") + const legacyGemini = path.join(legacyRoot, "auth", "gemini") + const legacyGrok = path.join(legacyRoot, "auth", "grok") const resolvedEnvGlobal = resolvePathFromBase(path, baseDir, paths.envGlobalPath) const resolvedEnvProject = resolvePathFromBase(path, baseDir, paths.envProjectPath) @@ -224,6 +226,14 @@ export const migrateLegacyOrchLayout = ( yield* _(copyDirIfEmpty(fs, path, legacyCodex, resolvedCodex, "Codex auth")) yield* _(copyDirIfEmpty(fs, path, legacyGh, resolvedGh, "GH auth")) yield* _(copyDirIfEmpty(fs, path, legacyClaude, resolvedClaude, "Claude auth")) + if (paths.geminiAuthPath !== undefined) { + const resolvedGemini = resolvePathFromBase(path, baseDir, paths.geminiAuthPath) + yield* _(copyDirIfEmpty(fs, path, legacyGemini, resolvedGemini, "Gemini auth")) + } + if (paths.grokAuthPath !== undefined) { + const resolvedGrok = resolvePathFromBase(path, baseDir, paths.grokAuthPath) + yield* _(copyDirIfEmpty(fs, path, legacyGrok, resolvedGrok, "Grok auth")) + } }) ) /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/usecases/shared-volume-seed.ts b/packages/app/src/lib/usecases/shared-volume-seed.ts index ee772e75..e6fecab9 100644 --- a/packages/app/src/lib/usecases/shared-volume-seed.ts +++ b/packages/app/src/lib/usecases/shared-volume-seed.ts @@ -23,11 +23,30 @@ const resolvePathFromBase = ( targetPath: string ): string => (path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath)) +const copyFileIfPresent = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const info = yield* _(statIfPresent(fs, sourcePath)) + if (info === null || info.type !== "File") { + return + } + const sourceText = yield* _(readFileStringIfPresent(fs, sourcePath)) + if (sourceText === null) { + return + } + yield* _(writeFileStringEnsuringParent(fs, path, targetPath, sourceText)) + }) + const copyDirRecursive = ( fs: FileSystem.FileSystem, path: Path.Path, sourceDir: string, - targetDir: string + targetDir: string, + shouldCopyEntry: (entry: string) => boolean = () => true ): Effect.Effect => Effect.gen(function*(_) { const info = yield* _(statIfPresent(fs, sourceDir)) @@ -37,41 +56,28 @@ const copyDirRecursive = ( yield* _(fs.makeDirectory(targetDir, { recursive: true })) const entries = yield* _(fs.readDirectory(sourceDir)) + const copyEntry = (entry: string): Effect.Effect => + Effect.gen(function*(_) { + const sourceEntry = path.join(sourceDir, entry) + const targetEntry = path.join(targetDir, entry) + const entryInfo = yield* _(statIfPresent(fs, sourceEntry)) + if (entryInfo === null) { + return + } + if (entryInfo.type === "Directory") { + yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry, shouldCopyEntry)) + return + } + if (entryInfo.type === "File") { + yield* _(copyFileIfPresent(fs, path, sourceEntry, targetEntry)) + } + }) for (const entry of entries) { - const sourceEntry = path.join(sourceDir, entry) - const targetEntry = path.join(targetDir, entry) - const entryInfo = yield* _(statIfPresent(fs, sourceEntry)) - if (entryInfo === null) { + if (!shouldCopyEntry(entry)) { continue } - if (entryInfo.type === "Directory") { - yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry)) - } else if (entryInfo.type === "File") { - const sourceText = yield* _(readFileStringIfPresent(fs, sourceEntry)) - if (sourceText === null) { - continue - } - yield* _(writeFileStringEnsuringParent(fs, path, targetEntry, sourceText)) - } - } - }) - -const copyFileIfPresent = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const info = yield* _(statIfPresent(fs, sourcePath)) - if (info === null || info.type !== "File") { - return + yield* _(copyEntry(entry)) } - const sourceText = yield* _(readFileStringIfPresent(fs, sourcePath)) - if (sourceText === null) { - return - } - yield* _(writeFileStringEnsuringParent(fs, path, targetPath, sourceText)) }) const copyCodexAuthFileIfPresent = ( @@ -108,10 +114,16 @@ const copyLabeledCodexFiles = ( } }) -type BootstrapSeedConfig = Pick< - TemplateConfig, - "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" -> +type BootstrapSeedConfig = + & Pick< + TemplateConfig, + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + > + & { readonly grokAuthPath?: TemplateConfig["grokAuthPath"] } type BootstrapSnapshotSources = { readonly authorizedKeysSource: string @@ -120,6 +132,7 @@ type BootstrapSnapshotSources = { readonly codexAuthSource: string readonly codexSharedAuthSource: string readonly claudeAuthSource: string + readonly grokAuthSources: ReadonlyArray } type BootstrapSnapshotTargets = { @@ -128,22 +141,50 @@ type BootstrapSnapshotTargets = { readonly envProjectTarget: string readonly projectCodexTarget: string readonly projectClaudeTarget: string + readonly projectGrokTarget: string readonly sharedCodexTarget: string } +const normalizeBootstrapPath = (value: string): string => + value + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .trim() + +const isLegacyDockerGitGrokAuthPath = (value: string): boolean => + normalizeBootstrapPath(value) === ".docker-git/.orch/auth/grok" + +const uniquePaths = (values: ReadonlyArray): ReadonlyArray => [...new Set(values)] + +const resolveBootstrapGrokAuthSources = ( + path: Path.Path, + projectDir: string, + grokAuthPath: string | undefined, + codexSharedAuthSource: string +): ReadonlyArray => { + const effectiveGrokAuthPath = grokAuthPath ?? path.join(path.dirname(codexSharedAuthSource), "grok") + const configured = resolvePathFromBase(path, projectDir, effectiveGrokAuthPath) + if (!isLegacyDockerGitGrokAuthPath(effectiveGrokAuthPath)) { + return [configured] + } + return uniquePaths([path.join(path.dirname(codexSharedAuthSource), "grok"), configured]) +} + const resolveBootstrapSnapshotSources = ( path: Path.Path, projectDir: string, config: BootstrapSeedConfig ): BootstrapSnapshotSources => { const codexAuthSource = resolvePathFromBase(path, projectDir, config.codexAuthPath) + const codexSharedAuthSource = resolvePathFromBase(path, projectDir, config.codexSharedAuthPath) return { authorizedKeysSource: resolvePathFromBase(path, projectDir, config.authorizedKeysPath), envGlobalSource: resolvePathFromBase(path, projectDir, config.envGlobalPath), envProjectSource: resolvePathFromBase(path, projectDir, config.envProjectPath), codexAuthSource, - codexSharedAuthSource: resolvePathFromBase(path, projectDir, config.codexSharedAuthPath), - claudeAuthSource: path.join(path.dirname(codexAuthSource), "claude") + codexSharedAuthSource, + claudeAuthSource: path.join(path.dirname(codexAuthSource), "claude"), + grokAuthSources: resolveBootstrapGrokAuthSources(path, projectDir, config.grokAuthPath, codexSharedAuthSource) } } @@ -162,6 +203,7 @@ const resolveBootstrapSnapshotTargets = ( envProjectTarget: path.join(stagingDir, "env-project", envProjectBase), projectCodexTarget: path.join(stagingDir, "project-auth", "codex"), projectClaudeTarget: path.join(stagingDir, "project-auth", "claude"), + projectGrokTarget: path.join(stagingDir, "project-auth", "grok"), sharedCodexTarget: path.join(stagingDir, "shared-auth", "codex") } } @@ -177,6 +219,7 @@ const ensureBootstrapSnapshotLayout = ( yield* _(fs.makeDirectory(path.dirname(targets.envProjectTarget), { recursive: true })) yield* _(fs.makeDirectory(targets.projectCodexTarget, { recursive: true })) yield* _(fs.makeDirectory(targets.projectClaudeTarget, { recursive: true })) + yield* _(fs.makeDirectory(targets.projectGrokTarget, { recursive: true })) yield* _(fs.makeDirectory(targets.sharedCodexTarget, { recursive: true })) }) @@ -208,6 +251,17 @@ const copyBootstrapSnapshotAuthDirs = ( copyCodexAuthFileIfPresent(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json") ) yield* _(copyLabeledCodexFiles(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json")) + for (const grokAuthSource of sources.grokAuthSources) { + yield* _( + copyDirRecursive( + fs, + path, + grokAuthSource, + targets.projectGrokTarget, + (entry) => entry !== ".image" && entry !== "tmp" && entry !== "log" && entry !== "logs" + ) + ) + } }) export const stageBootstrapSnapshot = ( @@ -229,10 +283,7 @@ export const stageBootstrapSnapshot = ( export const ensureProjectBootstrapVolumeReady = ( projectDir: string, - config: Pick< - TemplateConfig, - "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" - > + config: Pick & BootstrapSeedConfig ): Effect.Effect => Effect.scoped( Effect.gen(function*(_) { @@ -247,10 +298,7 @@ export const ensureProjectBootstrapVolumeReady = ( export const ensureSharedCodexVolumeReady = ( cwd: string, - config: Pick< - TemplateConfig, - "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" - > + config: Pick & BootstrapSeedConfig ): Effect.Effect => Effect.gen(function*(_) { yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCacheVolumeName)) diff --git a/packages/app/src/lib/usecases/state-normalize.ts b/packages/app/src/lib/usecases/state-normalize.ts index e1c764e6..b07a8670 100644 --- a/packages/app/src/lib/usecases/state-normalize.ts +++ b/packages/app/src/lib/usecases/state-normalize.ts @@ -30,7 +30,8 @@ const pathFieldsForNormalization = (template: TemplateConfig): ReadonlyArray @@ -56,6 +57,8 @@ const normalizeTemplateConfig = ( const codexAuthPath = "./.orch/auth/codex" const codexSharedAbs = path.join(projectsRoot, ".orch", "auth", "codex") const codexSharedRel = toPosixPath(path.relative(projectDir, codexSharedAbs)) + const grokAuthAbs = path.join(projectsRoot, ".orch", "auth", "grok") + const grokAuthRel = toPosixPath(path.relative(projectDir, grokAuthAbs)) return { ...template, @@ -64,7 +67,8 @@ const normalizeTemplateConfig = ( envGlobalPath, envProjectPath, codexAuthPath, - codexSharedAuthPath: withFallback(codexSharedRel, "./.orch/auth/codex") + codexSharedAuthPath: withFallback(codexSharedRel, "./.orch/auth/codex"), + grokAuthPath: withFallback(grokAuthRel, "./.orch/auth/grok") } } diff --git a/packages/app/src/shared/auth-stream-markers.ts b/packages/app/src/shared/auth-stream-markers.ts index be3bb7c3..085f4246 100644 --- a/packages/app/src/shared/auth-stream-markers.ts +++ b/packages/app/src/shared/auth-stream-markers.ts @@ -68,6 +68,14 @@ const providerLoginFailureMessage = ( export const githubLoginFailureMessage = (output: string, exitCode: string | null): string => providerLoginFailureMessage("GitHub", output, exitCode, githubLoginStreamMarkers) +export const codexLoginFailureMessage = (output: string, exitCode: string | null): string => { + if (output.includes("429 Too Many Requests")) { + return "Codex device auth is rate-limited by OpenAI (429 Too Many Requests). Wait a few minutes and retry." + } + + return providerLoginFailureMessage("Codex", output, exitCode, codexLoginStreamMarkers) +} + export const gitlabLoginFailureMessage = (output: string, exitCode: string | null): string => providerLoginFailureMessage("GitLab", output, exitCode, gitlabLoginStreamMarkers) diff --git a/packages/app/src/web/actions-auth.ts b/packages/app/src/web/actions-auth.ts index 40c497a0..7c96bb9f 100644 --- a/packages/app/src/web/actions-auth.ts +++ b/packages/app/src/web/actions-auth.ts @@ -19,6 +19,7 @@ import { createProjectAuthActionPrompt, validateActionPrompt } from "./action-prompt.js" +import { runCodexOauthMutation } from "./actions-codex-oauth.js" import { runGithubOauthMutation } from "./actions-github-oauth.js" import { applyAuthSuccessState, @@ -34,6 +35,7 @@ import { loadAuthSnapshot, loadGithubStatus, loadProjectAuthSnapshot, + logoutCodex, runAuthMenuFlow, runProjectAuthFlow } from "./api.js" @@ -135,6 +137,30 @@ const runSupportedAuthMutation = ( }) } +const runCodexLogoutMutation = ( + values: Readonly>, + context: BrowserActionContext +) => { + const label = defaultLabel(values["label"]) + withBusy({ + context, + effect: logoutCodex(nullableValue(values["label"])).pipe( + Effect.zipRight(Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + })) + ), + label: "CodexLogout", + onSuccess: ({ githubStatus, snapshot }) => { + applyAuthSuccessState(context, { + githubStatus, + message: authSuccessMessage("CodexLogout", label), + snapshot + }) + } + }) +} + const runTerminalOnlyAuthAction = ( action: TerminalAuthFlow, values: Readonly>, @@ -283,6 +309,14 @@ export const submitBrowserActionPrompt = ( runSupportedAuthMutation(prompt.action, prompt.values, context) return } + if (prompt.action === "CodexOauth") { + runCodexOauthMutation(prompt.values, context) + return + } + if (prompt.action === "CodexLogout") { + runCodexLogoutMutation(prompt.values, context) + return + } if (prompt.action === "ClaudeOauth" || prompt.action === "GeminiOauth" || prompt.action === "GrokOauth") { runTerminalOnlyAuthAction(prompt.action, prompt.values, context) return diff --git a/packages/app/src/web/actions-codex-oauth.ts b/packages/app/src/web/actions-codex-oauth.ts new file mode 100644 index 00000000..afb29bc3 --- /dev/null +++ b/packages/app/src/web/actions-codex-oauth.ts @@ -0,0 +1,55 @@ +import { Effect } from "effect" + +import { + authStreamMarkerExitCode, + authStreamSucceeded, + codexLoginFailureMessage, + codexLoginStreamMarkers, + makeVisibleAuthStreamWriter +} from "../shared/auth-stream-markers.js" +import { + appendOutputChunk, + applyAuthSuccessState, + type BrowserActionContext, + defaultLabel, + nullableValue, + withBusy +} from "./actions-shared.js" +import { loadAuthSnapshot, loadGithubStatus, loginCodexStream } from "./api.js" + +export const runCodexOauthMutation = ( + values: Readonly>, + context: BrowserActionContext +) => { + const label = defaultLabel(values["label"]) + const writer = makeVisibleAuthStreamWriter(codexLoginStreamMarkers, (chunk) => { + appendOutputChunk(context, chunk) + }) + context.setOutput("") + context.setMessage("Codex OAuth запущен. Следуй инструкциям в Output.") + withBusy({ + context, + effect: loginCodexStream(nullableValue(values["label"]), writer.writeChunk).pipe( + Effect.ensuring(Effect.sync(writer.flushVisiblePending)), + Effect.flatMap((output) => + authStreamSucceeded(output, codexLoginStreamMarkers) + ? Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + }) + : Effect.fail(codexLoginFailureMessage( + output, + authStreamMarkerExitCode(output, codexLoginStreamMarkers) + )) + ) + ), + label: "Running Codex OAuth", + onSuccess: ({ githubStatus, snapshot }) => { + applyAuthSuccessState(context, { + githubStatus, + message: `Saved Codex login (${label}).`, + snapshot + }) + } + }) +} diff --git a/packages/app/src/web/api-auth-schema.ts b/packages/app/src/web/api-auth-schema.ts index 1a60f80c..d15eec9d 100644 --- a/packages/app/src/web/api-auth-schema.ts +++ b/packages/app/src/web/api-auth-schema.ts @@ -26,6 +26,8 @@ export const GithubStatusResponseSchema = Schema.Struct({ const AuthProviderSnapshotFields = { claudeAuthEntries: Schema.Number, claudeAuthPath: Schema.String, + codexAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }), + codexAuthPath: Schema.optionalWith(Schema.String, { default: () => "" }), geminiAuthEntries: Schema.Number, geminiAuthPath: Schema.String, grokAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }), diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 3052904e..8fc60dd0 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -289,6 +289,17 @@ export const loginGithubStream = (label: string | null, onChunk: (chunk: string) path: "/auth/github/login/stream" }) +export const loginCodexStream = (label: string | null, onChunk: (chunk: string) => void) => + requestTextStream({ + body: { label }, + method: "POST", + onChunk, + path: "/auth/codex/login" + }) + +export const logoutCodex = (label: string | null) => + requestText("POST", "/auth/codex/logout", { label }).pipe(Effect.asVoid) + export const loadProjectEvents = ( projectId: string, cursor?: number diff --git a/packages/app/src/web/panel-auth.tsx b/packages/app/src/web/panel-auth.tsx index 67abf228..1fdbac83 100644 --- a/packages/app/src/web/panel-auth.tsx +++ b/packages/app/src/web/panel-auth.tsx @@ -10,7 +10,7 @@ const actionHint = (action: string | null): string | undefined => { if (action === "ClaudeOauth" || action === "GeminiOauth" || action === "GrokOauth") { return "opens embedded terminal" } - if (action === "GithubOauth") { + if (action === "GithubOauth" || action === "CodexOauth") { return "controller web login" } return undefined @@ -78,6 +78,7 @@ export const AuthPanel = ( + diff --git a/packages/app/tests/docker-git/actions-auth.test.ts b/packages/app/tests/docker-git/actions-auth.test.ts index a01cef2d..19a371ff 100644 --- a/packages/app/tests/docker-git/actions-auth.test.ts +++ b/packages/app/tests/docker-git/actions-auth.test.ts @@ -2,19 +2,25 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { beforeEach, vi } from "vitest" +import type { TerminalAuthFlow } from "../../src/docker-git/menu-auth-shared.js" import { createAuthActionPrompt } from "../../src/web/action-prompt.js" import { submitBrowserActionPrompt } from "../../src/web/actions-auth.js" import type { TerminalSession } from "../../src/web/api.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" const createAuthTerminalSessionMock = vi.hoisted(() => vi.fn()) +const loginCodexStreamMock = vi.hoisted(() => vi.fn()) +const loadAuthSnapshotMock = vi.hoisted(() => vi.fn()) +const loadGithubStatusMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ createAuthTerminalSession: createAuthTerminalSessionMock, - loadAuthSnapshot: vi.fn(), - loadGithubStatus: vi.fn(), + loadAuthSnapshot: loadAuthSnapshotMock, + loadGithubStatus: loadGithubStatusMock, loadProjectAuthSnapshot: vi.fn(), + loginCodexStream: loginCodexStreamMock, loginGithubStream: vi.fn(), + logoutCodex: vi.fn(), runAuthMenuFlow: vi.fn(), runProjectAuthFlow: vi.fn() })) @@ -27,37 +33,90 @@ const session: TerminalSession = { status: "ready" } +const assertTerminalOauthAction = ( + action: TerminalAuthFlow, + title: string +) => + Effect.gen(function*(_) { + createAuthTerminalSessionMock.mockImplementation(() => Effect.succeed(session)) + const addTerminalSession = vi.fn() + const { context, setMessage } = makeBrowserActionContext({ + addTerminalSession + }) + + submitBrowserActionPrompt(createAuthActionPrompt(action), context) + + yield* _(waitForAssertion(() => { + expect(addTerminalSession).toHaveBeenCalledTimes(1) + })) + + expect(createAuthTerminalSessionMock).toHaveBeenCalledWith(action, null) + expect(context.setActionPrompt).toHaveBeenCalledWith(null) + expect(addTerminalSession).toHaveBeenCalledWith(expect.objectContaining({ + closePath: "/auth/terminal-sessions/auth-session-1", + exitMessage: `${title} finished (default).`, + header: title, + pendingDeleteMessage: `${title} was closed before attach.`, + readyMessage: `${title} started (default).`, + session, + subtitle: "ssh dev@auth", + websocketPath: "/auth/terminal-sessions/auth-session-1/ws" + })) + expect(setMessage).toHaveBeenLastCalledWith(`${title} is opening in the embedded terminal.`) + }) + describe("web auth actions", () => { beforeEach(() => { createAuthTerminalSessionMock.mockReset() + loginCodexStreamMock.mockReset() + loadAuthSnapshotMock.mockReset() + loadGithubStatusMock.mockReset() }) it.effect("adds OAuth terminal sessions without replacing existing terminal state", () => + assertTerminalOauthAction("ClaudeOauth", "Claude Code OAuth")) + + it.effect("opens Grok OAuth through the same terminal-session path as docker-git auth grok login", () => + assertTerminalOauthAction("GrokOauth", "Grok CLI OAuth")) + + it.effect("opens Gemini OAuth through the shared terminal-session path", () => + assertTerminalOauthAction("GeminiOauth", "Gemini CLI OAuth")) + + it.effect("does not route Codex OAuth through a terminal session", () => Effect.gen(function*(_) { - createAuthTerminalSessionMock.mockImplementation(() => Effect.succeed(session)) - const addTerminalSession = vi.fn() - const { context, setMessage } = makeBrowserActionContext({ - addTerminalSession - }) + loginCodexStreamMock.mockImplementation((_label: string | null, onChunk: (chunk: string) => void) => + Effect.sync(() => { + onChunk("__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok\n") + return "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok\n" + }) + ) + loadAuthSnapshotMock.mockImplementation(() => + Effect.succeed({ + claudeAuthEntries: 0, + claudeAuthPath: "/home/dev/.docker-git/.orch/auth/claude", + codexAuthEntries: 1, + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + geminiAuthEntries: 0, + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + grokAuthEntries: 0, + grokAuthPath: "/home/dev/.docker-git/.orch/auth/grok", + gitTokenEntries: 0, + gitUserEntries: 0, + githubTokenEntries: 0, + globalEnvPath: "/home/dev/.docker-git/.orch/env/global.env", + totalEntries: 0 + }) + ) + loadGithubStatusMock.mockImplementation(() => Effect.succeed({ summary: "GitHub tokens (0):", tokens: [] })) + const { context } = makeBrowserActionContext() - submitBrowserActionPrompt(createAuthActionPrompt("ClaudeOauth"), context) + submitBrowserActionPrompt(createAuthActionPrompt("CodexOauth"), context) yield* _(waitForAssertion(() => { - expect(addTerminalSession).toHaveBeenCalledTimes(1) + expect(context.setAuthSnapshot).toHaveBeenCalledTimes(1) })) - expect(createAuthTerminalSessionMock).toHaveBeenCalledWith("ClaudeOauth", null) - expect(context.setActionPrompt).toHaveBeenCalledWith(null) - expect(addTerminalSession).toHaveBeenCalledWith(expect.objectContaining({ - closePath: "/auth/terminal-sessions/auth-session-1", - exitMessage: "Claude Code OAuth finished (default).", - header: "Claude Code OAuth", - pendingDeleteMessage: "Claude Code OAuth was closed before attach.", - readyMessage: "Claude Code OAuth started (default).", - session, - subtitle: "ssh dev@auth", - websocketPath: "/auth/terminal-sessions/auth-session-1/ws" - })) - expect(setMessage).toHaveBeenLastCalledWith("Claude Code OAuth is opening in the embedded terminal.") + expect(createAuthTerminalSessionMock).not.toHaveBeenCalled() + expect(loginCodexStreamMock).toHaveBeenCalledWith(null, expect.any(Function)) })) }) diff --git a/packages/app/tests/docker-git/actions-codex-oauth.test.ts b/packages/app/tests/docker-git/actions-codex-oauth.test.ts new file mode 100644 index 00000000..2030e246 --- /dev/null +++ b/packages/app/tests/docker-git/actions-codex-oauth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { vi } from "vitest" + +import { codexLoginStreamMarkers } from "../../src/shared/auth-stream-markers.js" +import { runCodexOauthMutation } from "../../src/web/actions-codex-oauth.js" +import type { AuthSnapshot, GithubAuthStatus } from "../../src/web/api.js" +import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" + +const loginCodexStreamMock = vi.hoisted(() => vi.fn()) +const loadAuthSnapshotMock = vi.hoisted(() => vi.fn()) +const loadGithubStatusMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadAuthSnapshot: loadAuthSnapshotMock, + loadGithubStatus: loadGithubStatusMock, + loginCodexStream: loginCodexStreamMock +})) + +const githubStatus: GithubAuthStatus = { + summary: "GitHub tokens (0):", + tokens: [] +} + +const authSnapshot: AuthSnapshot = { + claudeAuthEntries: 0, + claudeAuthPath: "/home/dev/.docker-git/.orch/auth/claude", + codexAuthEntries: 1, + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + geminiAuthEntries: 0, + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + grokAuthEntries: 0, + grokAuthPath: "/home/dev/.docker-git/.orch/auth/grok", + gitTokenEntries: 0, + gitUserEntries: 0, + githubTokenEntries: 0, + globalEnvPath: "/home/dev/.docker-git/.orch/env/global.env", + totalEntries: 0 +} + +describe("web Codex OAuth action", () => { + it.effect("uses the Codex login stream and refreshes the auth snapshot", () => + Effect.gen(function*(_) { + loginCodexStreamMock.mockImplementation((_label: string | null, onChunk: (chunk: string) => void) => + Effect.sync(() => { + onChunk("Open this URL to sign in: https://auth.openai.com/example\n") + onChunk(`${codexLoginStreamMarkers.success}\n`) + return [ + "Open this URL to sign in: https://auth.openai.com/example", + codexLoginStreamMarkers.success + ].join("\n") + }) + ) + loadAuthSnapshotMock.mockImplementation(() => Effect.succeed(authSnapshot)) + loadGithubStatusMock.mockImplementation(() => Effect.succeed(githubStatus)) + + const { context, output, setMessage } = makeBrowserActionContext() + + runCodexOauthMutation({ label: "" }, context) + + yield* _(waitForAssertion(() => { + expect(context.setAuthSnapshot).toHaveBeenCalledWith(authSnapshot) + })) + + expect(output()).toBe("Open this URL to sign in: https://auth.openai.com/example\n") + expect(context.setActionPrompt).toHaveBeenCalledWith(null) + expect(context.setGithubStatus).toHaveBeenCalledWith(githubStatus) + expect(setMessage).toHaveBeenLastCalledWith("Saved Codex login (default).") + })) +}) diff --git a/packages/app/tests/docker-git/actions-github-oauth.test.ts b/packages/app/tests/docker-git/actions-github-oauth.test.ts index b1101eec..a4c7be5b 100644 --- a/packages/app/tests/docker-git/actions-github-oauth.test.ts +++ b/packages/app/tests/docker-git/actions-github-oauth.test.ts @@ -32,6 +32,8 @@ const githubStatus: GithubAuthStatus = { const authSnapshot: AuthSnapshot = { claudeAuthEntries: 0, claudeAuthPath: "/home/dev/.docker-git/.orch/auth/claude", + codexAuthEntries: 0, + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", geminiAuthEntries: 0, geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", grokAuthEntries: 0, diff --git a/packages/app/tests/docker-git/api-auth-schema.test.ts b/packages/app/tests/docker-git/api-auth-schema.test.ts index cf9ba1d7..9ec6ed51 100644 --- a/packages/app/tests/docker-git/api-auth-schema.test.ts +++ b/packages/app/tests/docker-git/api-auth-schema.test.ts @@ -23,7 +23,7 @@ const decodeAuthSnapshotResponse = (payload: LegacyAuthSnapshotResponse) => ParseResult.decodeUnknownEither(Schema.parseJson(AuthSnapshotResponseSchema))(JSON.stringify(payload)) describe("web auth api schema", () => { - it("accepts auth snapshots from controllers without Grok fields", () => { + it("accepts auth snapshots from controllers without Codex and Grok fields", () => { const decoded = decodeAuthSnapshotResponse({ snapshot: { claudeAuthEntries: 3, @@ -40,6 +40,8 @@ describe("web auth api schema", () => { expect(Either.isRight(decoded)).toBe(true) if (Either.isRight(decoded)) { + expect(decoded.right.snapshot.codexAuthEntries).toBe(0) + expect(decoded.right.snapshot.codexAuthPath).toBe("") expect(decoded.right.snapshot.grokAuthEntries).toBe(0) expect(decoded.right.snapshot.grokAuthPath).toBe("") } diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index b6a1ea8b..3db675fc 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -3,8 +3,8 @@ import { expect, vi } from "vitest" import { type CreateFlowView, - type DisplayModeFlowView, createInitialFlowView, + type DisplayModeFlowView, resolveCreateDisplaySteps } from "../../src/docker-git/menu-create-shared.js" import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" diff --git a/packages/app/tests/docker-git/auth-stream-markers.test.ts b/packages/app/tests/docker-git/auth-stream-markers.test.ts index 010a42a1..1fb8bf5c 100644 --- a/packages/app/tests/docker-git/auth-stream-markers.test.ts +++ b/packages/app/tests/docker-git/auth-stream-markers.test.ts @@ -4,6 +4,8 @@ import { authStreamMarkerExitCode, authStreamSucceeded, authStreamVisibleLines, + codexLoginFailureMessage, + codexLoginStreamMarkers, githubLoginFailureMessage, githubLoginStreamMarkers, gitlabLoginFailureMessage, @@ -46,6 +48,20 @@ describe("auth stream markers", () => { expect(authStreamVisibleLines(output, gitlabLoginStreamMarkers)).toEqual(["GitLab login failed"]) }) + it("detects Codex stream markers and rate-limit failures", () => { + const output = [ + "Codex login failed: 429 Too Many Requests", + `${codexLoginStreamMarkers.errorPrefix}1` + ].join("\n") + + expect(authStreamSucceeded(`${codexLoginStreamMarkers.success}\n`, codexLoginStreamMarkers)).toBe(true) + expect(authStreamMarkerExitCode(output, codexLoginStreamMarkers)).toBe("1") + expect(codexLoginFailureMessage(output, "1")).toContain("rate-limited") + expect(authStreamVisibleLines(output, codexLoginStreamMarkers)).toEqual([ + "Codex login failed: 429 Too Many Requests" + ]) + }) + it("filters marker lines from chunked visible output", () => { const chunks: Array = [] const writer = makeVisibleAuthStreamWriter(githubLoginStreamMarkers, (chunk) => { diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index 5ced2495..aa91a40e 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -42,6 +42,15 @@ const getGeneratedFilePaths = (files: ReadonlyArray): ReadonlyArray files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) describe("app planFiles", () => { + it("includes Grok auth bootstrap wiring in the generated entrypoint", () => { + const files = planFiles(makeTemplateConfig()) + const entrypoint = getGeneratedFile(files, "entrypoint.sh") + + expect(entrypoint.contents).toContain("DOCKER_GIT_GROK_AUTH_DIR=\"$DOCKER_GIT_HOME/.orch/auth/grok\"") + expect(entrypoint.contents).toContain("BOOTSTRAP_GROK_AUTH_DIR=\"$BOOTSTRAP_SOURCE_ROOT/project-auth/grok\"") + expect(entrypoint.contents).toContain("sync_dir_entries \"$BOOTSTRAP_GROK_AUTH_DIR\" \"$DOCKER_GIT_GROK_AUTH_DIR\"") + }) + it("includes nested browser runtime artifacts when Playwright is enabled", () => { const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) const filePaths = getGeneratedFilePaths(files) diff --git a/packages/app/tests/docker-git/create-flow-render.test.ts b/packages/app/tests/docker-git/create-flow-render.test.ts index b5fc170d..74774a0d 100644 --- a/packages/app/tests/docker-git/create-flow-render.test.ts +++ b/packages/app/tests/docker-git/create-flow-render.test.ts @@ -7,8 +7,8 @@ import { type CreateFlowContext, type CreateFlowView, createInitialFlowView, - createSettingsHint, type CreateModeFlowView, + createSettingsHint, type DisplayModeFlowView, renderCreateStepLabel, resolveCreateDisplaySteps, diff --git a/packages/app/tests/docker-git/program.test.ts b/packages/app/tests/docker-git/program.test.ts index 9ba7bdad..c59920fd 100644 --- a/packages/app/tests/docker-git/program.test.ts +++ b/packages/app/tests/docker-git/program.test.ts @@ -10,6 +10,8 @@ const runBrowserFrontendCommandMock = vi.hoisted(() => vi.fn(() => Effect.void)) const runMenuCallMock = vi.hoisted(() => vi.fn(() => {})) const readCommandMock = vi.hoisted(() => vi.fn<() => Command>()) const codexLoginMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const createAuthTerminalSessionMock = vi.hoisted(() => vi.fn()) +const attachTerminalSessionMock = vi.hoisted(() => vi.fn(() => Effect.void)) const gitlabLoginMock = vi.hoisted(() => vi.fn(() => Effect.succeed({ ok: true }))) const readStatePullMock = vi.hoisted(() => vi.fn(() => Effect.succeed("State pull completed."))) @@ -26,6 +28,17 @@ const gitlabLoginCommand: Extract token: "glpat-token", envGlobalPath: ".docker-git/.orch/env/global.env" } +const claudeLoginCommand: Extract = { + _tag: "AuthClaudeLogin", + label: "work", + claudeAuthPath: ".docker-git/.orch/auth/claude" +} +const geminiLoginCommand: Extract = { + _tag: "AuthGeminiLogin", + label: null, + geminiAuthPath: ".docker-git/.orch/auth/gemini", + isWeb: false +} const statePullCommand: Extract = { _tag: "StatePull" } vi.mock("../../src/docker-git/cli/read-command.js", () => ({ @@ -47,6 +60,7 @@ vi.mock("../../src/docker-git/api-client.js", () => ({ codexImport: vi.fn(() => Effect.succeed({ ok: true })), codexLogout: vi.fn(() => Effect.void), codexStatus: vi.fn(() => Effect.succeed({ ok: true })), + createAuthTerminalSession: createAuthTerminalSessionMock, createProject: vi.fn(() => Effect.succeed(null)), downAllProjects: vi.fn(() => Effect.void), gitlabLogin: gitlabLoginMock, @@ -70,6 +84,10 @@ vi.mock("../../src/docker-git/api-client.js", () => ({ syncState: vi.fn(() => Effect.succeed("State sync completed.")) })) +vi.mock("../../src/docker-git/terminal-session-client.js", () => ({ + attachTerminalSession: attachTerminalSessionMock +})) + vi.mock("../../src/docker-git/menu.js", () => ({ runMenu: Effect.sync(() => { runMenuCallMock() @@ -93,6 +111,18 @@ describe("program menu dispatch", () => { readCommandMock.mockReturnValue(menuCommand) codexLoginMock.mockReset() codexLoginMock.mockImplementation(() => Effect.void) + createAuthTerminalSessionMock.mockReset() + createAuthTerminalSessionMock.mockImplementation(() => + Effect.succeed({ + createdAt: "2026-04-21T10:00:00.000Z", + id: "auth-session-1", + projectId: "auth", + sshCommand: "ssh dev@auth", + status: "ready" + }) + ) + attachTerminalSessionMock.mockReset() + attachTerminalSessionMock.mockImplementation(() => Effect.void) gitlabLoginMock.mockReset() gitlabLoginMock.mockImplementation(() => Effect.succeed({ ok: true })) readStatePullMock.mockReset() @@ -149,4 +179,26 @@ describe("program menu dispatch", () => { expect(gitlabLoginMock).toHaveBeenCalledTimes(1) expect(process.exitCode ?? 0).toBe(0) })) + + it.effect("routes claude login through controller auth terminal sessions", () => + Effect.gen(function*(_) { + readCommandMock.mockReturnValue(claudeLoginCommand) + yield* _(runProgram()) + + expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) + expect(createAuthTerminalSessionMock).toHaveBeenCalledWith("ClaudeOauth", "work") + expect(attachTerminalSessionMock).toHaveBeenCalledTimes(1) + expect(process.exitCode ?? 0).toBe(0) + })) + + it.effect("routes gemini login through controller auth terminal sessions", () => + Effect.gen(function*(_) { + readCommandMock.mockReturnValue(geminiLoginCommand) + yield* _(runProgram()) + + expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) + expect(createAuthTerminalSessionMock).toHaveBeenCalledWith("GeminiOauth", null) + expect(attachTerminalSessionMock).toHaveBeenCalledTimes(1) + expect(process.exitCode ?? 0).toBe(0) + })) }) diff --git a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts index 9b8d3ff9..84fec87f 100644 --- a/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts +++ b/packages/lib/src/core/templates-entrypoint/nested-docker-git.ts @@ -5,6 +5,7 @@ const entrypointDockerGitBootstrapTemplate = String DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git" DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex" DOCKER_GIT_CLAUDE_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/claude" +DOCKER_GIT_GROK_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/grok" DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env" DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env" DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env" @@ -15,10 +16,11 @@ BOOTSTRAP_AUTH_KEYS="$BOOTSTRAP_SOURCE_ROOT/authorized-keys/__AUTHORIZED_KEYS_BA BOOTSTRAP_CODEX_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/codex" BOOTSTRAP_CODEX_SHARED_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/shared-auth/codex" BOOTSTRAP_CLAUDE_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/claude" +BOOTSTRAP_GROK_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/grok" BOOTSTRAP_ENV_GLOBAL="$BOOTSTRAP_SOURCE_ROOT/env-global/__ENV_GLOBAL_BASENAME__" BOOTSTRAP_ENV_PROJECT="$BOOTSTRAP_SOURCE_ROOT/env-project/__ENV_PROJECT_BASENAME__" -mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" +mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh" sync_file_if_present() { local source="$1" @@ -191,6 +193,7 @@ copy_if_distinct_file() { sync_dir_entries "$BOOTSTRAP_CODEX_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" sync_labeled_auth_files "$BOOTSTRAP_CODEX_SHARED_AUTH_DIR" "$DOCKER_GIT_AUTH_DIR" sync_dir_entries "$BOOTSTRAP_CLAUDE_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" +sync_dir_entries "$BOOTSTRAP_GROK_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR" if [[ -n "$GH_TOKEN" ]]; then upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN" diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 287c2d96..48af5359 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -7,6 +7,7 @@ import { Effect } from "effect" import type { CreateCommand, ParseError } from "../../core/domain.js" import { deriveRepoPathParts } from "../../core/domain.js" +import { defaultTemplateConfig } from "../../core/template-defaults.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" import type { AgentFailedError, @@ -66,6 +67,11 @@ const makeCreateContext = (path: Path.Path, baseDir: string): CreateContext => { return { baseDir, resolveRootPath } } +const resolveConfigGrokAuthPath = (config: CreateCommand["config"]): string => { + const legacyConfig: { readonly grokAuthPath?: string } = config + return legacyConfig.grokAuthPath ?? defaultTemplateConfig.grokAuthPath +} + const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): CreateCommand["config"] => ({ ...command.config, dockerGitPath: ctx.resolveRootPath(command.config.dockerGitPath), @@ -73,7 +79,8 @@ const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): Create envGlobalPath: ctx.resolveRootPath(command.config.envGlobalPath), envProjectPath: ctx.resolveRootPath(command.config.envProjectPath), codexAuthPath: ctx.resolveRootPath(command.config.codexAuthPath), - codexSharedAuthPath: ctx.resolveRootPath(command.config.codexSharedAuthPath) + codexSharedAuthPath: ctx.resolveRootPath(command.config.codexSharedAuthPath), + grokAuthPath: ctx.resolveRootPath(resolveConfigGrokAuthPath(command.config)) }) const resolveCreateConfig = ( diff --git a/packages/lib/src/usecases/actions/paths.ts b/packages/lib/src/usecases/actions/paths.ts index f89ee1c7..41b8f368 100644 --- a/packages/lib/src/usecases/actions/paths.ts +++ b/packages/lib/src/usecases/actions/paths.ts @@ -1,11 +1,17 @@ import type * as Path from "@effect/platform/Path" import type { CreateCommand } from "../../core/domain.js" +import { defaultTemplateConfig } from "../../core/template-defaults.js" export const resolvePathFromBase = (path: Path.Path, baseDir: string, targetPath: string): string => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) const toPosixPath = (value: string): string => value.replaceAll("\\", "/") +const resolveConfigGrokAuthPath = (config: CreateCommand["config"]): string => { + const legacyConfig: { readonly grokAuthPath?: string } = config + return legacyConfig.grokAuthPath ?? defaultTemplateConfig.grokAuthPath +} + export const resolveDockerGitRootRelativePath = ( path: Path.Path, projectsRoot: string, @@ -41,6 +47,7 @@ export const buildProjectConfigs = ( // docker-compose resolves relative host paths from the project directory (where docker-compose.yml lives). // To keep generated projects portable across host OSes, we avoid embedding absolute host paths in templates. const relativeFromOutDir = (absolutePath: string): string => toPosixPath(path.relative(resolvedOutDir, absolutePath)) + const grokAuthPath = resolveConfigGrokAuthPath(resolvedConfig) const globalConfig = { ...resolvedConfig, @@ -49,7 +56,8 @@ export const buildProjectConfigs = ( envGlobalPath: resolvePathFromBase(path, baseDir, resolvedConfig.envGlobalPath), envProjectPath: resolvePathFromBase(path, baseDir, resolvedConfig.envProjectPath), codexAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexAuthPath), - codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath) + codexSharedAuthPath: resolvePathFromBase(path, baseDir, resolvedConfig.codexSharedAuthPath), + grokAuthPath: resolvePathFromBase(path, baseDir, grokAuthPath) } const projectConfig = { ...resolvedConfig, @@ -62,7 +70,9 @@ export const buildProjectConfigs = ( // Project-local Codex state (sessions/logs/etc) is kept under .orch. codexAuthPath: "./.orch/auth/codex", // Keep the global auth source path so runtime can seed the shared Docker volume when containers start. - codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath) + codexSharedAuthPath: relativeFromOutDir(globalConfig.codexSharedAuthPath), + // Keep the global Grok source path so runtime bootstrap can seed selected Grok labels. + grokAuthPath: relativeFromOutDir(globalConfig.grokAuthPath) } return { globalConfig, projectConfig } } diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index e859bf62..099f89a4 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -322,5 +322,7 @@ export const migrateProjectOrchLayout = ( envProjectPath: globalConfig.envProjectPath, codexAuthPath: globalConfig.codexAuthPath, ghAuthPath: resolveRootPath(".docker-git/.orch/auth/gh"), - claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude") + claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude"), + geminiAuthPath: resolveRootPath(".docker-git/.orch/auth/gemini"), + grokAuthPath: resolveRootPath(".docker-git/.orch/auth/grok") }) diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index 56bc0ab5..0706417e 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -6,11 +6,11 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -// CHANGE: run the official Grok CLI device-auth flow inside the auth container -// WHY: `docker-git auth grok login` must work from terminal-only containers without callback URL handling +// CHANGE: run the official Grok CLI OAuth/browser flow inside the auth container +// WHY: `docker-git auth grok login` must use the same interactive Grok CLI login as the CLI path // REF: issue-304 // SOURCE: https://x.ai/news/grok-build-cli -// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> device_code_authorized -> grok_credentials_stored | error +// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> browser_oauth_authorized -> grok_credentials_stored | error // PURITY: SHELL // EFFECT: Effect // INVARIANT: Grok credentials are stored in ~/.grok within the selected account path @@ -41,15 +41,15 @@ const buildDockerGrokAuthSpec = ( }) /** - * Builds the Docker CLI argument vector for the official Grok device-code login flow. + * Builds the Docker CLI argument vector for the official Grok OAuth/browser login flow. * * @param spec Docker auth container paths, image, working directory, and environment bindings. - * @returns Immutable Docker argument vector ending with `grok login --device-auth`. + * @returns Immutable Docker argument vector ending with `grok login`. * @pure true * @effect none; CORE argument builder only transforms immutable input data. * @invariant every non-empty environment binding is emitted as an adjacent `-e` argument pair. * @precondition spec.hostPath and spec.containerPath identify the selected Grok auth account directory. - * @postcondition returned args execute the official headless Grok login mode documented by xAI. + * @postcondition returned args execute the same official interactive Grok login command as the CLI route. * @complexity O(n) time / O(n) space, where n is spec.env.length. * @throws Never - invalid process execution is represented by callers through typed Effect errors. */ @@ -73,16 +73,16 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray } base.push("-e", trimmed) } - return [...base, spec.image, "grok", "login", "--device-auth"] + return [...base, spec.image, "grok", "login"] } -const printDeviceAuthInstructions = (): Effect.Effect => +const printOauthInstructions = (): Effect.Effect => Effect.sync(() => { process.stderr.write("\n") - process.stderr.write("Grok CLI Device Authentication\n") - process.stderr.write("1. Copy the device code printed by the Grok CLI.\n") - process.stderr.write("2. Open the verification URL printed by the CLI in a browser.\n") - process.stderr.write("3. Complete approval; this terminal continues after the CLI writes credentials.\n") + process.stderr.write("Grok CLI OAuth Authentication\n") + process.stderr.write("1. Open the Grok sign-in URL printed by the CLI.\n") + process.stderr.write("2. Complete browser authentication.\n") + process.stderr.write("3. If the callback cannot connect, paste the returned URL into the prompt.\n") process.stderr.write("\n") }) @@ -119,10 +119,10 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st ) /** - * Runs the Grok CLI `--device-auth` login inside the docker-git auth container. + * Runs the Grok CLI OAuth/browser login inside the docker-git auth container. * - * The CLI prints a device code and verification URL; after the user completes - * approval externally, the command exits and credentials are normalized. + * The CLI prints a sign-in URL and may ask for the callback URL; after OAuth + * completes, credentials are normalized. * * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. @@ -133,7 +133,7 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st * @invariant successful completion leaves credentials scoped to accountPath and not to project source files. * @precondition Docker is available and options.image contains the official Grok CLI binary. * @postcondition accountPath ownership follows the mounted account root or a typed error is returned. - * @complexity O(n) local argument construction plus unbounded external device authorization time. + * @complexity O(n) local argument construction plus unbounded external OAuth interaction time. * @throws Never - failures are modeled as AuthError, CommandFailedError, or PlatformError in the Effect type. */ export const runGrokOauthLoginWithPrompt = ( @@ -145,7 +145,7 @@ export const runGrokOauthLoginWithPrompt = ( } ): Effect.Effect => Effect.gen(function*(_) { - yield* _(printDeviceAuthInstructions()) + yield* _(printOauthInstructions()) const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) const spec = buildDockerGrokAuthSpec(cwd, hostPath, options.image, options.containerPath) yield* _( diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 236a7690..556393cd 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -211,6 +211,8 @@ export const migrateLegacyOrchLayout = ( const legacyCodex = path.join(legacyRoot, "auth", "codex") const legacyGh = path.join(legacyRoot, "auth", "gh") const legacyClaude = path.join(legacyRoot, "auth", "claude") + const legacyGemini = path.join(legacyRoot, "auth", "gemini") + const legacyGrok = path.join(legacyRoot, "auth", "grok") const resolvedEnvGlobal = resolvePathFromBase(path, baseDir, paths.envGlobalPath) const resolvedEnvProject = resolvePathFromBase(path, baseDir, paths.envProjectPath) @@ -223,5 +225,13 @@ export const migrateLegacyOrchLayout = ( yield* _(copyDirIfEmpty(fs, path, legacyCodex, resolvedCodex, "Codex auth")) yield* _(copyDirIfEmpty(fs, path, legacyGh, resolvedGh, "GH auth")) yield* _(copyDirIfEmpty(fs, path, legacyClaude, resolvedClaude, "Claude auth")) + if (paths.geminiAuthPath !== undefined) { + const resolvedGemini = resolvePathFromBase(path, baseDir, paths.geminiAuthPath) + yield* _(copyDirIfEmpty(fs, path, legacyGemini, resolvedGemini, "Gemini auth")) + } + if (paths.grokAuthPath !== undefined) { + const resolvedGrok = resolvePathFromBase(path, baseDir, paths.grokAuthPath) + yield* _(copyDirIfEmpty(fs, path, legacyGrok, resolvedGrok, "Grok auth")) + } }) ) diff --git a/packages/lib/src/usecases/shared-volume-seed.ts b/packages/lib/src/usecases/shared-volume-seed.ts index e43d8a39..bec64e13 100644 --- a/packages/lib/src/usecases/shared-volume-seed.ts +++ b/packages/lib/src/usecases/shared-volume-seed.ts @@ -22,11 +22,30 @@ const resolvePathFromBase = ( targetPath: string ): string => (path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath)) +const copyFileIfPresent = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + Effect.gen(function*(_) { + const info = yield* _(statIfPresent(fs, sourcePath)) + if (info === null || info.type !== "File") { + return + } + const sourceText = yield* _(readFileStringIfPresent(fs, sourcePath)) + if (sourceText === null) { + return + } + yield* _(writeFileStringEnsuringParent(fs, path, targetPath, sourceText)) + }) + const copyDirRecursive = ( fs: FileSystem.FileSystem, path: Path.Path, sourceDir: string, - targetDir: string + targetDir: string, + shouldCopyEntry: (entry: string) => boolean = () => true ): Effect.Effect => Effect.gen(function*(_) { const info = yield* _(statIfPresent(fs, sourceDir)) @@ -36,41 +55,28 @@ const copyDirRecursive = ( yield* _(fs.makeDirectory(targetDir, { recursive: true })) const entries = yield* _(fs.readDirectory(sourceDir)) + const copyEntry = (entry: string): Effect.Effect => + Effect.gen(function*(_) { + const sourceEntry = path.join(sourceDir, entry) + const targetEntry = path.join(targetDir, entry) + const entryInfo = yield* _(statIfPresent(fs, sourceEntry)) + if (entryInfo === null) { + return + } + if (entryInfo.type === "Directory") { + yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry, shouldCopyEntry)) + return + } + if (entryInfo.type === "File") { + yield* _(copyFileIfPresent(fs, path, sourceEntry, targetEntry)) + } + }) for (const entry of entries) { - const sourceEntry = path.join(sourceDir, entry) - const targetEntry = path.join(targetDir, entry) - const entryInfo = yield* _(statIfPresent(fs, sourceEntry)) - if (entryInfo === null) { + if (!shouldCopyEntry(entry)) { continue } - if (entryInfo.type === "Directory") { - yield* _(copyDirRecursive(fs, path, sourceEntry, targetEntry)) - } else if (entryInfo.type === "File") { - const sourceText = yield* _(readFileStringIfPresent(fs, sourceEntry)) - if (sourceText === null) { - continue - } - yield* _(writeFileStringEnsuringParent(fs, path, targetEntry, sourceText)) - } - } - }) - -const copyFileIfPresent = ( - fs: FileSystem.FileSystem, - path: Path.Path, - sourcePath: string, - targetPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const info = yield* _(statIfPresent(fs, sourcePath)) - if (info === null || info.type !== "File") { - return + yield* _(copyEntry(entry)) } - const sourceText = yield* _(readFileStringIfPresent(fs, sourcePath)) - if (sourceText === null) { - return - } - yield* _(writeFileStringEnsuringParent(fs, path, targetPath, sourceText)) }) const copyCodexAuthFileIfPresent = ( @@ -107,10 +113,16 @@ const copyLabeledCodexFiles = ( } }) -type BootstrapSeedConfig = Pick< - TemplateConfig, - "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" -> +type BootstrapSeedConfig = + & Pick< + TemplateConfig, + | "authorizedKeysPath" + | "envGlobalPath" + | "envProjectPath" + | "codexAuthPath" + | "codexSharedAuthPath" + > + & { readonly grokAuthPath?: TemplateConfig["grokAuthPath"] } type BootstrapSnapshotSources = { readonly authorizedKeysSource: string @@ -119,6 +131,7 @@ type BootstrapSnapshotSources = { readonly codexAuthSource: string readonly codexSharedAuthSource: string readonly claudeAuthSource: string + readonly grokAuthSources: ReadonlyArray } type BootstrapSnapshotTargets = { @@ -127,22 +140,50 @@ type BootstrapSnapshotTargets = { readonly envProjectTarget: string readonly projectCodexTarget: string readonly projectClaudeTarget: string + readonly projectGrokTarget: string readonly sharedCodexTarget: string } +const normalizeBootstrapPath = (value: string): string => + value + .replaceAll("\\", "/") + .replace(/^\.\//, "") + .trim() + +const isLegacyDockerGitGrokAuthPath = (value: string): boolean => + normalizeBootstrapPath(value) === ".docker-git/.orch/auth/grok" + +const uniquePaths = (values: ReadonlyArray): ReadonlyArray => [...new Set(values)] + +const resolveBootstrapGrokAuthSources = ( + path: Path.Path, + projectDir: string, + grokAuthPath: string | undefined, + codexSharedAuthSource: string +): ReadonlyArray => { + const effectiveGrokAuthPath = grokAuthPath ?? path.join(path.dirname(codexSharedAuthSource), "grok") + const configured = resolvePathFromBase(path, projectDir, effectiveGrokAuthPath) + if (!isLegacyDockerGitGrokAuthPath(effectiveGrokAuthPath)) { + return [configured] + } + return uniquePaths([path.join(path.dirname(codexSharedAuthSource), "grok"), configured]) +} + const resolveBootstrapSnapshotSources = ( path: Path.Path, projectDir: string, config: BootstrapSeedConfig ): BootstrapSnapshotSources => { const codexAuthSource = resolvePathFromBase(path, projectDir, config.codexAuthPath) + const codexSharedAuthSource = resolvePathFromBase(path, projectDir, config.codexSharedAuthPath) return { authorizedKeysSource: resolvePathFromBase(path, projectDir, config.authorizedKeysPath), envGlobalSource: resolvePathFromBase(path, projectDir, config.envGlobalPath), envProjectSource: resolvePathFromBase(path, projectDir, config.envProjectPath), codexAuthSource, - codexSharedAuthSource: resolvePathFromBase(path, projectDir, config.codexSharedAuthPath), - claudeAuthSource: path.join(path.dirname(codexAuthSource), "claude") + codexSharedAuthSource, + claudeAuthSource: path.join(path.dirname(codexAuthSource), "claude"), + grokAuthSources: resolveBootstrapGrokAuthSources(path, projectDir, config.grokAuthPath, codexSharedAuthSource) } } @@ -161,6 +202,7 @@ const resolveBootstrapSnapshotTargets = ( envProjectTarget: path.join(stagingDir, "env-project", envProjectBase), projectCodexTarget: path.join(stagingDir, "project-auth", "codex"), projectClaudeTarget: path.join(stagingDir, "project-auth", "claude"), + projectGrokTarget: path.join(stagingDir, "project-auth", "grok"), sharedCodexTarget: path.join(stagingDir, "shared-auth", "codex") } } @@ -176,6 +218,7 @@ const ensureBootstrapSnapshotLayout = ( yield* _(fs.makeDirectory(path.dirname(targets.envProjectTarget), { recursive: true })) yield* _(fs.makeDirectory(targets.projectCodexTarget, { recursive: true })) yield* _(fs.makeDirectory(targets.projectClaudeTarget, { recursive: true })) + yield* _(fs.makeDirectory(targets.projectGrokTarget, { recursive: true })) yield* _(fs.makeDirectory(targets.sharedCodexTarget, { recursive: true })) }) @@ -207,6 +250,17 @@ const copyBootstrapSnapshotAuthDirs = ( copyCodexAuthFileIfPresent(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json") ) yield* _(copyLabeledCodexFiles(fs, path, sources.codexSharedAuthSource, targets.sharedCodexTarget, "auth.json")) + for (const grokAuthSource of sources.grokAuthSources) { + yield* _( + copyDirRecursive( + fs, + path, + grokAuthSource, + targets.projectGrokTarget, + (entry) => entry !== ".image" && entry !== "tmp" && entry !== "log" && entry !== "logs" + ) + ) + } }) export const stageBootstrapSnapshot = ( @@ -228,10 +282,7 @@ export const stageBootstrapSnapshot = ( export const ensureProjectBootstrapVolumeReady = ( projectDir: string, - config: Pick< - TemplateConfig, - "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" - > + config: Pick & BootstrapSeedConfig ): Effect.Effect => Effect.scoped( Effect.gen(function*(_) { @@ -246,10 +297,7 @@ export const ensureProjectBootstrapVolumeReady = ( export const ensureSharedCodexVolumeReady = ( cwd: string, - config: Pick< - TemplateConfig, - "volumeName" | "authorizedKeysPath" | "envGlobalPath" | "envProjectPath" | "codexAuthPath" | "codexSharedAuthPath" - > + config: Pick & BootstrapSeedConfig ): Effect.Effect => Effect.gen(function*(_) { yield* _(runDockerVolumeCreate(cwd, dockerGitSharedCacheVolumeName)) diff --git a/packages/lib/src/usecases/state-normalize.ts b/packages/lib/src/usecases/state-normalize.ts index 18fc923d..0506c92c 100644 --- a/packages/lib/src/usecases/state-normalize.ts +++ b/packages/lib/src/usecases/state-normalize.ts @@ -29,7 +29,8 @@ const pathFieldsForNormalization = (template: TemplateConfig): ReadonlyArray @@ -55,6 +56,8 @@ const normalizeTemplateConfig = ( const codexAuthPath = "./.orch/auth/codex" const codexSharedAbs = path.join(projectsRoot, ".orch", "auth", "codex") const codexSharedRel = toPosixPath(path.relative(projectDir, codexSharedAbs)) + const grokAuthAbs = path.join(projectsRoot, ".orch", "auth", "grok") + const grokAuthRel = toPosixPath(path.relative(projectDir, grokAuthAbs)) return { ...template, @@ -63,7 +66,8 @@ const normalizeTemplateConfig = ( envGlobalPath, envProjectPath, codexAuthPath, - codexSharedAuthPath: withFallback(codexSharedRel, "./.orch/auth/codex") + codexSharedAuthPath: withFallback(codexSharedRel, "./.orch/auth/codex"), + grokAuthPath: withFallback(grokAuthRel, "./.orch/auth/grok") } } diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index b357a86e..deb35533 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -566,6 +566,19 @@ describe("renderEntrypoint auth bridge", () => { expect(linkIndex).toBeGreaterThan(dirGuardIndex) }) + it("renders Grok auth bootstrap wiring into the container docker-git home", () => { + const entrypoint = renderAuthEntrypoint() + + expectContainsAll(entrypoint, [ + 'DOCKER_GIT_GROK_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/grok"', + 'BOOTSTRAP_GROK_AUTH_DIR="$BOOTSTRAP_SOURCE_ROOT/project-auth/grok"', + 'mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_CLAUDE_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR"', + 'sync_dir_entries "$BOOTSTRAP_GROK_AUTH_DIR" "$DOCKER_GIT_GROK_AUTH_DIR"', + 'export GROK_CONFIG_DIR="$GROK_AUTH_ROOT/$GROK_LABEL_NORM"', + 'docker_git_link_grok_file "$GROK_CONFIG_DIR/.api-key" "$GROK_HOME_DIR/.api-key"' + ]) + }) + it("renders system-prompt override hooks for codex/claude/gemini/grok", () => { const entrypoint = renderAuthEntrypoint() diff --git a/packages/lib/tests/usecases/auth-grok.test.ts b/packages/lib/tests/usecases/auth-grok.test.ts index fb6fd8cc..778a6293 100644 --- a/packages/lib/tests/usecases/auth-grok.test.ts +++ b/packages/lib/tests/usecases/auth-grok.test.ts @@ -91,7 +91,7 @@ describe("authGrokLogin", () => { expect(dockerfile).not.toContain("npm install -g grok-dev") }) - it("uses the official Grok device-auth login mode", () => { + it("uses the official interactive Grok login mode", () => { const args = buildDockerGrokAuthArgs({ cwd: "/workspace", image: "docker-git-auth-grok:latest", @@ -103,7 +103,7 @@ describe("authGrokLogin", () => { expect(args).toContain("MCP_PLAYWRIGHT_ISOLATED=1") expect(args).not.toContain("NO_BROWSER=true") expect(args).not.toContain("GROK_NO_BROWSER=true") - expect(args.slice(-4)).toEqual(["docker-git-auth-grok:latest", "grok", "login", "--device-auth"]) + expect(args.slice(-3)).toEqual(["docker-git-auth-grok:latest", "grok", "login"]) }) it.effect("stores API key and writes Grok settings with Playwright MCP and no sandbox", () => diff --git a/packages/lib/tests/usecases/auth-sync.test.ts b/packages/lib/tests/usecases/auth-sync.test.ts index 70f8b290..aa67477b 100644 --- a/packages/lib/tests/usecases/auth-sync.test.ts +++ b/packages/lib/tests/usecases/auth-sync.test.ts @@ -392,6 +392,44 @@ describe("syncGithubAuthKeys", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("migrates legacy Grok auth directory into docker-git root", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const legacyGrokDefault = path.join(root, ".orch", "auth", "grok", "default") + const legacyGrokHome = path.join(legacyGrokDefault, ".grok") + + yield* _(fs.makeDirectory(legacyGrokHome, { recursive: true })) + yield* _(fs.writeFileString(path.join(legacyGrokDefault, ".api-key"), "xai-legacy\n")) + yield* _(fs.writeFileString(path.join(legacyGrokHome, "auth.json"), "{\"oauth\":\"legacy\"}\n")) + + yield* _( + migrateLegacyOrchLayout(root, { + envGlobalPath: ".docker-git/.orch/env/global.env", + envProjectPath: ".orch/env/project.env", + codexAuthPath: ".docker-git/.orch/auth/codex", + ghAuthPath: ".docker-git/.orch/auth/gh", + claudeAuthPath: ".docker-git/.orch/auth/claude", + grokAuthPath: ".docker-git/.orch/auth/grok" + }) + ) + + const migratedGrokDefault = path.join( + root, + ".docker-git", + ".orch", + "auth", + "grok", + "default" + ) + expect(yield* _(fs.readFileString(path.join(migratedGrokDefault, ".api-key")))).toBe("xai-legacy\n") + expect(yield* _(fs.readFileString(path.join(migratedGrokDefault, ".grok", "auth.json")))).toBe( + "{\"oauth\":\"legacy\"}\n" + ) + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("seeds Claude auth from host home into docker-git default account", () => withTempDir((root) => Effect.gen(function*(_) { diff --git a/packages/lib/tests/usecases/shared-volume-seed.test.ts b/packages/lib/tests/usecases/shared-volume-seed.test.ts index 9302f211..0be1234a 100644 --- a/packages/lib/tests/usecases/shared-volume-seed.test.ts +++ b/packages/lib/tests/usecases/shared-volume-seed.test.ts @@ -30,7 +30,7 @@ const failOnCopyFile = ( }) describe("stageBootstrapSnapshot", () => { - it.effect("copies stable Codex auth files and skips transient broken tmp entries", () => + it.effect("copies stable Codex and Grok auth files and skips transient entries", () => withTempDir((root) => Effect.gen(function*(_) { const fileSystem = yield* _(FileSystem.FileSystem) @@ -43,6 +43,11 @@ describe("stageBootstrapSnapshot", () => { const projectClaudeDir = path.join(projectDir, ".orch", "auth", "claude", "default") const sharedCodexDir = path.join(root, ".docker-git", ".orch", "auth", "codex") const sharedCodexLabelDir = path.join(sharedCodexDir, "team-a") + const sharedGrokDir = path.join(root, ".docker-git", ".orch", "auth", "grok") + const sharedGrokDefaultDir = path.join(sharedGrokDir, "default") + const sharedGrokCredentialsDir = path.join(sharedGrokDefaultDir, ".grok") + const sharedGrokLabelDir = path.join(sharedGrokDir, "team-a") + const sharedGrokLabelCredentialsDir = path.join(sharedGrokLabelDir, ".grok") const envDir = path.join(projectDir, ".orch", "env") yield* _(fileSystem.makeDirectory(projectCodexDir, { recursive: true })) @@ -50,6 +55,8 @@ describe("stageBootstrapSnapshot", () => { yield* _(fileSystem.makeDirectory(projectClaudeDir, { recursive: true })) yield* _(fileSystem.makeDirectory(sharedCodexDir, { recursive: true })) yield* _(fileSystem.makeDirectory(sharedCodexLabelDir, { recursive: true })) + yield* _(fileSystem.makeDirectory(sharedGrokCredentialsDir, { recursive: true })) + yield* _(fileSystem.makeDirectory(sharedGrokLabelCredentialsDir, { recursive: true })) yield* _(fileSystem.makeDirectory(envDir, { recursive: true })) yield* _(fileSystem.writeFileString(path.join(projectDir, "authorized_keys"), "ssh-ed25519 test\n")) yield* _(fileSystem.writeFileString(path.join(envDir, "global.env"), "GITHUB_TOKEN=test\n")) @@ -61,6 +68,14 @@ describe("stageBootstrapSnapshot", () => { yield* _(fileSystem.writeFileString(path.join(projectClaudeDir, ".oauth-token"), "claude-token\n")) yield* _(fileSystem.writeFileString(path.join(sharedCodexDir, "auth.json"), "{\"shared\":true}\n")) yield* _(fileSystem.writeFileString(path.join(sharedCodexLabelDir, "auth.json"), "{\"shared\":\"team-a\"}\n")) + yield* _(fileSystem.writeFileString(path.join(sharedGrokDefaultDir, ".api-key"), "xai-default\n")) + yield* _(fileSystem.writeFileString(path.join(sharedGrokDefaultDir, ".env"), "GROK_DEPLOYMENT_KEY=xai-env\n")) + yield* _(fileSystem.writeFileString(path.join(sharedGrokCredentialsDir, "auth.json"), "{\"oauth\":\"default\"}\n")) + yield* _( + fileSystem.writeFileString(path.join(sharedGrokCredentialsDir, "user-settings.json"), "{\"apiKey\":\"xai-default\"}\n") + ) + yield* _(fileSystem.writeFileString(path.join(sharedGrokLabelDir, ".api-key"), "xai-team-a\n")) + yield* _(fileSystem.writeFileString(path.join(sharedGrokLabelCredentialsDir, "auth.json"), "{\"oauth\":\"team-a\"}\n")) const brokenShimDir = path.join(sharedCodexDir, "tmp", "arg0", "codex-arg0broken") yield* _(fileSystem.makeDirectory(brokenShimDir, { recursive: true })) @@ -75,6 +90,12 @@ describe("stageBootstrapSnapshot", () => { yield* _(fileSystem.writeFileString(path.join(sharedCodexDir, "log", "codex-login.log"), "transient log\n")) yield* _(fileSystem.makeDirectory(path.join(sharedCodexDir, ".image"), { recursive: true })) yield* _(fileSystem.writeFileString(path.join(sharedCodexDir, ".image", "Dockerfile"), "FROM scratch\n")) + yield* _(fileSystem.makeDirectory(path.join(sharedGrokDir, "tmp"), { recursive: true })) + yield* _(fileSystem.writeFileString(path.join(sharedGrokDir, "tmp", "session.lock"), "lock\n")) + yield* _(fileSystem.makeDirectory(path.join(sharedGrokDir, "log"), { recursive: true })) + yield* _(fileSystem.writeFileString(path.join(sharedGrokDir, "log", "grok-login.log"), "transient log\n")) + yield* _(fileSystem.makeDirectory(path.join(sharedGrokDir, ".image"), { recursive: true })) + yield* _(fileSystem.writeFileString(path.join(sharedGrokDir, ".image", "Dockerfile"), "FROM scratch\n")) yield* _( stageBootstrapSnapshot(stagingDir, projectDir, { @@ -83,7 +104,8 @@ describe("stageBootstrapSnapshot", () => { envGlobalPath: path.join(projectDir, ".orch", "env", "global.env"), envProjectPath: path.join(projectDir, ".orch", "env", "project.env"), codexAuthPath: path.join(projectDir, ".orch", "auth", "codex"), - codexSharedAuthPath: sharedCodexDir + codexSharedAuthPath: sharedCodexDir, + grokAuthPath: "./.docker-git/.orch/auth/grok" }).pipe( Effect.provideService(FileSystem.FileSystem, failOnCopyFile(fileSystem, "stageBootstrapSnapshot")) ) @@ -110,11 +132,33 @@ describe("stageBootstrapSnapshot", () => { expect( yield* _(fileSystem.readFileString(path.join(stagingDir, "project-auth", "claude", "default", ".oauth-token"))) ).toBe("claude-token\n") + expect( + yield* _(fileSystem.readFileString(path.join(stagingDir, "project-auth", "grok", "default", ".api-key"))) + ).toBe("xai-default\n") + expect( + yield* _(fileSystem.readFileString(path.join(stagingDir, "project-auth", "grok", "default", ".env"))) + ).toBe("GROK_DEPLOYMENT_KEY=xai-env\n") + expect( + yield* _( + fileSystem.readFileString(path.join(stagingDir, "project-auth", "grok", "default", ".grok", "auth.json")) + ) + ).toBe("{\"oauth\":\"default\"}\n") + expect( + yield* _(fileSystem.readFileString(path.join(stagingDir, "project-auth", "grok", "team-a", ".api-key"))) + ).toBe("xai-team-a\n") + expect( + yield* _( + fileSystem.readFileString(path.join(stagingDir, "project-auth", "grok", "team-a", ".grok", "auth.json")) + ) + ).toBe("{\"oauth\":\"team-a\"}\n") expect(yield* _(fileSystem.exists(path.join(stagingDir, "shared-auth", "codex", "tmp")))).toBe(false) expect(yield* _(fileSystem.exists(path.join(stagingDir, "shared-auth", "codex", "log")))).toBe(false) expect(yield* _(fileSystem.exists(path.join(stagingDir, "shared-auth", "codex", ".image")))).toBe(false) expect(yield* _(fileSystem.exists(path.join(stagingDir, "project-auth", "codex", "tmp")))).toBe(false) + expect(yield* _(fileSystem.exists(path.join(stagingDir, "project-auth", "grok", "tmp")))).toBe(false) + expect(yield* _(fileSystem.exists(path.join(stagingDir, "project-auth", "grok", "log")))).toBe(false) + expect(yield* _(fileSystem.exists(path.join(stagingDir, "project-auth", "grok", ".image")))).toBe(false) }) ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/lib/tests/usecases/state-normalize.test.ts b/packages/lib/tests/usecases/state-normalize.test.ts new file mode 100644 index 00000000..5c9af834 --- /dev/null +++ b/packages/lib/tests/usecases/state-normalize.test.ts @@ -0,0 +1,66 @@ +import * as FileSystem from "@effect/platform/FileSystem" +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 { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" +import { readProjectConfig } from "../../src/shell/config.js" +import { normalizeLegacyStateProjects } from "../../src/usecases/state-normalize.js" + +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-state-normalize-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeLegacyTemplate = (): TemplateConfig => ({ + ...defaultTemplateConfig, + repoUrl: "https://github.com/org/repo.git", + repoRef: "issue-327", + containerName: "dg-state-normalize", + serviceName: "dg-state-normalize", + dockerGitPath: "./.docker-git", + authorizedKeysPath: "./.docker-git/authorized_keys", + envGlobalPath: "./.docker-git/.orch/env/global.env", + envProjectPath: "./.orch/env/project.env", + codexAuthPath: "./.docker-git/.orch/auth/codex", + codexSharedAuthPath: "./.docker-git/.orch/auth/codex", + grokAuthPath: "./.docker-git/.orch/auth/grok" +}) + +describe("normalizeLegacyStateProjects", () => { + it.effect("normalizes legacy Grok auth paths to the global state auth root", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectDir = path.join(root, "project") + const configPath = path.join(projectDir, "docker-git.json") + + yield* _(fs.makeDirectory(projectDir, { recursive: true })) + yield* _( + fs.writeFileString( + configPath, + `${JSON.stringify({ schemaVersion: 1, template: makeLegacyTemplate() }, null, 2)}\n` + ) + ) + + yield* _(normalizeLegacyStateProjects(root)) + + const config = yield* _(readProjectConfig(projectDir)) + expect(config.template.codexAuthPath).toBe("./.orch/auth/codex") + expect(config.template.codexSharedAuthPath).toBe("../.orch/auth/codex") + expect(config.template.grokAuthPath).toBe("../.orch/auth/grok") + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) From 3c203f6935d2042e3c166d5ea4eed9e00e448fa1 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 14:18:55 +0000 Subject: [PATCH 2/7] fix(auth): satisfy ci lint checks --- .../src/docker-git/menu-auth-file-helpers.ts | 27 ++++ .../app/src/docker-git/menu-auth-helpers.ts | 112 +++++++-------- .../docker-git/menu-auth-snapshot-builder.ts | 35 +++-- .../docker-git/menu-project-auth-helpers.ts | 22 +-- packages/app/src/docker-git/program-auth.ts | 2 +- packages/app/src/web/actions-auth.ts | 133 +++++++----------- packages/app/src/web/actions-codex-oauth.ts | 55 ++++---- packages/app/src/web/actions-github-oauth.ts | 61 ++------ packages/app/src/web/actions-terminal-auth.ts | 32 +++++ .../src/web/actions-visible-auth-stream.ts | 71 ++++++++++ .../app/tests/docker-git/actions-auth.test.ts | 2 + .../docker-git/actions-codex-oauth.test.ts | 2 + 12 files changed, 310 insertions(+), 244 deletions(-) create mode 100644 packages/app/src/docker-git/menu-auth-file-helpers.ts create mode 100644 packages/app/src/web/actions-terminal-auth.ts create mode 100644 packages/app/src/web/actions-visible-auth-stream.ts diff --git a/packages/app/src/docker-git/menu-auth-file-helpers.ts b/packages/app/src/docker-git/menu-auth-file-helpers.ts new file mode 100644 index 00000000..153a3d6a --- /dev/null +++ b/packages/app/src/docker-git/menu-auth-file-helpers.ts @@ -0,0 +1,27 @@ +import type { PlatformError } from "@effect/platform/Error" +import type * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +type FilePredicate = ( + fs: FileSystem.FileSystem, + filePath: string +) => Effect.Effect + +export const hasFileAtPath: FilePredicate = (fs, filePath) => + fs.stat(filePath).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false) + ) + +export const hasNonEmptyFile: FilePredicate = (fs, filePath) => + hasFileAtPath(fs, filePath).pipe( + Effect.flatMap((hasFile) => { + if (!hasFile) { + return Effect.succeed(false) + } + return fs.readFileString(filePath).pipe( + Effect.orElseSucceed(() => ""), + Effect.map((content) => content.trim().length > 0) + ) + }) + ) diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts index 80ae8b43..91135403 100644 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-auth-helpers.ts @@ -3,50 +3,34 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" +import { hasNonEmptyFile } from "./menu-auth-file-helpers.js" + type HasCredentials = ( fs: FileSystem.FileSystem, accountPath: string ) => Effect.Effect +type CredentialDirectoryCounterInput = { + readonly fs: FileSystem.FileSystem + readonly hasCredentials: HasCredentials + readonly path: Path.Path + readonly root: string +} + const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) -const hasFileAtPath = ( +export const hasCodexAccountCredentials = ( fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - const info = yield* _(fs.stat(filePath)) - return info.type === "File" - }) + accountPath: string +): Effect.Effect => hasNonEmptyFile(fs, `${accountPath}/auth.json`) -const hasNonEmptyFile = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => +const countCredentialDirectories = ({ + fs, + hasCredentials, + path, + root +}: CredentialDirectoryCounterInput): Effect.Effect => Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, filePath)) - if (!hasFile) { - return false - } - const content = yield* _(fs.readFileString(filePath), Effect.orElseSucceed(() => "")) - return content.trim().length > 0 - }) - -export const countAuthCredentialAccounts = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string, - hasCredentials: HasCredentials -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(root)) - if (!exists) { - return 0 - } const entries = yield* _(fs.readDirectory(root)) let count = 0 for (const entry of entries) { @@ -66,38 +50,44 @@ export const countAuthCredentialAccounts = ( return count }) -export const hasCodexAccountCredentials = ( +const countExistingCredentialDirectories = ( + input: CredentialDirectoryCounterInput +): Effect.Effect => + input.fs.exists(input.root).pipe( + Effect.flatMap((exists) => + exists + ? countCredentialDirectories(input) + : Effect.succeed(0) + ) + ) + +export const countAuthCredentialAccounts = ( fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - hasNonEmptyFile(fs, `${accountPath}/auth.json`) + path: Path.Path, + root: string, + hasCredentials: HasCredentials +): Effect.Effect => countExistingCredentialDirectories({ fs, hasCredentials, path, root }) export const countCodexCredentialAccounts = ( fs: FileSystem.FileSystem, path: Path.Path, root: string ): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(root)) - if (!exists) { - return 0 - } - - let count = yield* _(hasCodexAccountCredentials(fs, root), Effect.map((connected) => connected ? 1 : 0)) - const entries = yield* _(fs.readDirectory(root)) - for (const entry of entries) { - if (ignoredAuthAccountEntries.has(entry)) { - continue + fs.exists(root).pipe( + Effect.flatMap((exists) => { + if (!exists) { + return Effect.succeed(0) } - const accountPath = path.join(root, entry) - const info = yield* _(fs.stat(accountPath)) - if (info.type !== "Directory") { - continue - } - const connected = yield* _(hasCodexAccountCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) - if (connected) { - count += 1 - } - } - return count - }) + return Effect.all({ + directoryCount: countCredentialDirectories({ + fs, + hasCredentials: hasCodexAccountCredentials, + path, + root + }), + rootConnected: hasCodexAccountCredentials(fs, root).pipe(Effect.orElseSucceed(() => false)) + }).pipe( + Effect.map(({ directoryCount, rootConnected }) => directoryCount + (rootConnected ? 1 : 0)) + ) + }) + ) diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts index a8a78dcc..6d02e508 100644 --- a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts +++ b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts @@ -15,19 +15,38 @@ export type AuthAccountCounts = { readonly grokAuthEntries: number } +export type AuthAccountCountPaths = { + readonly claudeAuthPath: string + readonly codexAuthPath: string + readonly geminiAuthPath: string + readonly grokAuthPath: string +} + export const countAuthAccountEntries = ( fs: FileSystem.FileSystem, path: Path.Path, - claudeAuthPath: string, - codexAuthPath: string, - geminiAuthPath: string, - grokAuthPath: string + authPaths: AuthAccountCountPaths ): Effect.Effect => pipe( Effect.all({ - claudeAuthEntries: countAuthCredentialAccounts(fs, path, claudeAuthPath, hasClaudeAccountCredentials), - codexAuthEntries: countCodexCredentialAccounts(fs, path, codexAuthPath), - geminiAuthEntries: countAuthCredentialAccounts(fs, path, geminiAuthPath, hasGeminiAccountCredentials), - grokAuthEntries: countAuthCredentialAccounts(fs, path, grokAuthPath, hasGrokAccountCredentials) + claudeAuthEntries: countAuthCredentialAccounts( + fs, + path, + authPaths.claudeAuthPath, + hasClaudeAccountCredentials + ), + codexAuthEntries: countCodexCredentialAccounts(fs, path, authPaths.codexAuthPath), + geminiAuthEntries: countAuthCredentialAccounts( + fs, + path, + authPaths.geminiAuthPath, + hasGeminiAccountCredentials + ), + grokAuthEntries: countAuthCredentialAccounts( + fs, + path, + authPaths.grokAuthPath, + hasGrokAccountCredentials + ) }) ) diff --git a/packages/app/src/docker-git/menu-project-auth-helpers.ts b/packages/app/src/docker-git/menu-project-auth-helpers.ts index 493ba181..c72d0594 100644 --- a/packages/app/src/docker-git/menu-project-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-project-auth-helpers.ts @@ -2,30 +2,16 @@ import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import { Effect } from "effect" +import { hasFileAtPath, hasNonEmptyFile } from "./menu-auth-file-helpers.js" + +export { hasFileAtPath, hasNonEmptyFile } from "./menu-auth-file-helpers.js" + type AccountCredentialSpec = { readonly apiKeyFileName: string readonly envFileName: string readonly envKeys: ReadonlyArray } -export const hasFileAtPath = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - fs.stat(filePath).pipe( - Effect.map((info) => info.type === "File"), - Effect.catchAll(() => Effect.succeed(false)) - ) - -export const hasNonEmptyFile = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - fs.readFileString(filePath).pipe( - Effect.map((content) => content.trim().length > 0), - Effect.catchAll(() => Effect.succeed(false)) - ) - export const hasNonEmptyEnvValue = ( fs: FileSystem.FileSystem, envFilePath: string, diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index f3f97980..636c9f80 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -21,7 +21,7 @@ import { import { type ControllerRuntime, ensureControllerReady } from "./controller.js" import type { Command } from "./frontend-lib/core/domain.js" import type { ApiRequestError, CliError } from "./host-errors.js" -import { terminalAuthTitle, type TerminalAuthFlow } from "./menu-auth-shared.js" +import { type TerminalAuthFlow, terminalAuthTitle } from "./menu-auth-shared.js" import { attachTerminalSession } from "./terminal-session-client.js" type OperationalCommand = Exclude diff --git a/packages/app/src/web/actions-auth.ts b/packages/app/src/web/actions-auth.ts index 7c96bb9f..eb6757b9 100644 --- a/packages/app/src/web/actions-auth.ts +++ b/packages/app/src/web/actions-auth.ts @@ -5,8 +5,7 @@ import { authViewSteps, authViewTitle, successMessage as authSuccessMessage, - type TerminalAuthFlow, - terminalAuthTitle + type TerminalAuthFlow } from "../docker-git/menu-auth-shared.js" import { type ProjectAuthMenuAction, @@ -19,7 +18,7 @@ import { createProjectAuthActionPrompt, validateActionPrompt } from "./action-prompt.js" -import { runCodexOauthMutation } from "./actions-codex-oauth.js" +import { runCodexLogoutMutation, runCodexOauthMutation } from "./actions-codex-oauth.js" import { runGithubOauthMutation } from "./actions-github-oauth.js" import { applyAuthSuccessState, @@ -30,12 +29,11 @@ import { returnToMainMenu, withBusy } from "./actions-shared.js" +import { runTerminalOnlyAuthAction } from "./actions-terminal-auth.js" import { - createAuthTerminalSession, loadAuthSnapshot, loadGithubStatus, loadProjectAuthSnapshot, - logoutCodex, runAuthMenuFlow, runProjectAuthFlow } from "./api.js" @@ -67,6 +65,10 @@ type SupportedProjectMutation = Extract< | "ProjectGrokDisconnect" > +type BrowserAuthPrompt = Extract +type BrowserProjectAuthPrompt = Extract +type CodexAuthAction = Extract + export const refreshAuthPanel = (context: BrowserActionContext) => { withBusy({ context, @@ -137,59 +139,6 @@ const runSupportedAuthMutation = ( }) } -const runCodexLogoutMutation = ( - values: Readonly>, - context: BrowserActionContext -) => { - const label = defaultLabel(values["label"]) - withBusy({ - context, - effect: logoutCodex(nullableValue(values["label"])).pipe( - Effect.zipRight(Effect.all({ - githubStatus: loadGithubStatus(), - snapshot: loadAuthSnapshot() - })) - ), - label: "CodexLogout", - onSuccess: ({ githubStatus, snapshot }) => { - applyAuthSuccessState(context, { - githubStatus, - message: authSuccessMessage("CodexLogout", label), - snapshot - }) - } - }) -} - -const runTerminalOnlyAuthAction = ( - action: TerminalAuthFlow, - values: Readonly>, - context: BrowserActionContext -) => { - const provider = terminalAuthTitle(action) - const label = nullableValue(values["label"]) - const sessionLabel = defaultLabel(values["label"]) - withBusy({ - context, - effect: createAuthTerminalSession(action, label), - label: provider, - onSuccess: (session) => { - context.setActionPrompt(null) - context.addTerminalSession({ - closePath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}`, - exitMessage: `${provider} finished (${sessionLabel}).`, - header: provider, - pendingDeleteMessage: `${provider} was closed before attach.`, - readyMessage: `${provider} started (${sessionLabel}).`, - session, - subtitle: session.sshCommand, - websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` - }) - context.setMessage(`${provider} is opening in the embedded terminal.`) - } - }) -} - const runProjectAuthMutation = ( action: SupportedProjectMutation, values: Readonly>, @@ -237,6 +186,24 @@ const isMenuNavigationAction = ( action: AuthMenuAction | ProjectAuthMenuAction ): action is "Back" | "Refresh" => action === "Back" || action === "Refresh" +const isCodexAuthAction = (action: BrowserAuthPrompt["action"]): action is CodexAuthAction => + action === "CodexOauth" || action === "CodexLogout" + +const isTerminalOnlyAuthAction = (action: BrowserAuthPrompt["action"]): action is TerminalAuthFlow => + action === "ClaudeOauth" || action === "GeminiOauth" || action === "GrokOauth" + +const runCodexAuthAction = ( + action: CodexAuthAction, + values: Readonly>, + context: BrowserActionContext +) => { + if (action === "CodexOauth") { + runCodexOauthMutation(values, context) + return + } + runCodexLogoutMutation(values, context) +} + const handleBrowserMenuAction = ( action: "Back" | "Refresh", context: BrowserActionContext, @@ -295,6 +262,32 @@ export const cancelBrowserActionPrompt = ( context.setMessage(`${prompt.title} cancelled.`) } +const submitAuthPrompt = ( + prompt: BrowserAuthPrompt, + context: BrowserActionContext +) => { + if (isCodexAuthAction(prompt.action)) { + runCodexAuthAction(prompt.action, prompt.values, context) + return + } + if (isTerminalOnlyAuthAction(prompt.action)) { + runTerminalOnlyAuthAction(prompt.action, prompt.values, context) + return + } + runSupportedAuthMutation(prompt.action, prompt.values, context) +} + +const submitProjectAuthPrompt = ( + prompt: BrowserProjectAuthPrompt, + context: BrowserActionContext +) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + return + } + runProjectAuthMutation(prompt.action, prompt.values, projectId, context) +} + export const submitBrowserActionPrompt = ( prompt: ActionPromptState, context: BrowserActionContext @@ -305,28 +298,8 @@ export const submitBrowserActionPrompt = ( return } if (prompt.kind === "Auth") { - if (prompt.action === "GithubOauth") { - runSupportedAuthMutation(prompt.action, prompt.values, context) - return - } - if (prompt.action === "CodexOauth") { - runCodexOauthMutation(prompt.values, context) - return - } - if (prompt.action === "CodexLogout") { - runCodexLogoutMutation(prompt.values, context) - return - } - if (prompt.action === "ClaudeOauth" || prompt.action === "GeminiOauth" || prompt.action === "GrokOauth") { - runTerminalOnlyAuthAction(prompt.action, prompt.values, context) - return - } - runSupportedAuthMutation(prompt.action, prompt.values, context) + submitAuthPrompt(prompt, context) return } - const projectId = requireSelectedProjectId(context) - if (projectId === null) { - return - } - runProjectAuthMutation(prompt.action, prompt.values, projectId, context) + submitProjectAuthPrompt(prompt, context) } diff --git a/packages/app/src/web/actions-codex-oauth.ts b/packages/app/src/web/actions-codex-oauth.ts index afb29bc3..dc3f3038 100644 --- a/packages/app/src/web/actions-codex-oauth.ts +++ b/packages/app/src/web/actions-codex-oauth.ts @@ -1,53 +1,50 @@ import { Effect } from "effect" +import { codexLoginFailureMessage, codexLoginStreamMarkers } from "../shared/auth-stream-markers.js" import { - authStreamMarkerExitCode, - authStreamSucceeded, - codexLoginFailureMessage, - codexLoginStreamMarkers, - makeVisibleAuthStreamWriter -} from "../shared/auth-stream-markers.js" -import { - appendOutputChunk, applyAuthSuccessState, type BrowserActionContext, defaultLabel, nullableValue, withBusy } from "./actions-shared.js" -import { loadAuthSnapshot, loadGithubStatus, loginCodexStream } from "./api.js" +import { runVisibleAuthStreamMutation } from "./actions-visible-auth-stream.js" +import { loadAuthSnapshot, loadGithubStatus, loginCodexStream, logoutCodex } from "./api.js" export const runCodexOauthMutation = ( values: Readonly>, context: BrowserActionContext ) => { - const label = defaultLabel(values["label"]) - const writer = makeVisibleAuthStreamWriter(codexLoginStreamMarkers, (chunk) => { - appendOutputChunk(context, chunk) + runVisibleAuthStreamMutation({ + busyLabel: "Running Codex OAuth", + context, + failureMessage: codexLoginFailureMessage, + markers: codexLoginStreamMarkers, + runStream: loginCodexStream, + startMessage: "Codex OAuth запущен. Следуй инструкциям в Output.", + successMessage: (label) => `Saved Codex login (${label}).`, + values }) - context.setOutput("") - context.setMessage("Codex OAuth запущен. Следуй инструкциям в Output.") +} + +export const runCodexLogoutMutation = ( + values: Readonly>, + context: BrowserActionContext +) => { + const label = defaultLabel(values["label"]) withBusy({ context, - effect: loginCodexStream(nullableValue(values["label"]), writer.writeChunk).pipe( - Effect.ensuring(Effect.sync(writer.flushVisiblePending)), - Effect.flatMap((output) => - authStreamSucceeded(output, codexLoginStreamMarkers) - ? Effect.all({ - githubStatus: loadGithubStatus(), - snapshot: loadAuthSnapshot() - }) - : Effect.fail(codexLoginFailureMessage( - output, - authStreamMarkerExitCode(output, codexLoginStreamMarkers) - )) - ) + effect: logoutCodex(nullableValue(values["label"])).pipe( + Effect.zipRight(Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + })) ), - label: "Running Codex OAuth", + label: "CodexLogout", onSuccess: ({ githubStatus, snapshot }) => { applyAuthSuccessState(context, { githubStatus, - message: `Saved Codex login (${label}).`, + message: `Logged out Codex CLI (${label}).`, snapshot }) } diff --git a/packages/app/src/web/actions-github-oauth.ts b/packages/app/src/web/actions-github-oauth.ts index c3bbd813..cbefe1d7 100644 --- a/packages/app/src/web/actions-github-oauth.ts +++ b/packages/app/src/web/actions-github-oauth.ts @@ -1,56 +1,23 @@ -import { Effect } from "effect" - -import { - authStreamMarkerExitCode, - authStreamSucceeded, - githubLoginFailureMessage, - githubLoginStreamMarkers, - makeVisibleAuthStreamWriter -} from "../shared/auth-stream-markers.js" -import { - appendOutputChunk, - applyAuthSuccessState, - type BrowserActionContext, - defaultLabel, - nullableValue, - withBusy -} from "./actions-shared.js" -import { loadAuthSnapshot, loadGithubStatus, loginGithubStream } from "./api.js" +import { githubLoginFailureMessage, githubLoginStreamMarkers } from "../shared/auth-stream-markers.js" +import type { BrowserActionContext } from "./actions-shared.js" +import { runVisibleAuthStreamMutation } from "./actions-visible-auth-stream.js" +import { loginGithubStream } from "./api.js" export const runGithubOauthMutation = ( values: Readonly>, context: BrowserActionContext ) => { - const label = defaultLabel(values["label"]) - const writer = makeVisibleAuthStreamWriter(githubLoginStreamMarkers, (chunk) => { - appendOutputChunk(context, chunk) - }) - context.setOutput("") - context.setMessage("GitHub OAuth запущен. Следуй инструкциям в Output.") - withBusy({ + runVisibleAuthStreamMutation({ + busyLabel: "Running GitHub OAuth", context, - effect: loginGithubStream(nullableValue(values["label"]), writer.writeChunk).pipe( - Effect.ensuring(Effect.sync(writer.flushVisiblePending)), - Effect.flatMap((output) => - authStreamSucceeded(output, githubLoginStreamMarkers) - ? Effect.all({ - githubStatus: loadGithubStatus(), - snapshot: loadAuthSnapshot() - }) - : Effect.fail(githubLoginFailureMessage( - output, - authStreamMarkerExitCode(output, githubLoginStreamMarkers) - )) - ) - ), - label: "Running GitHub OAuth", - onSuccess: ({ githubStatus, snapshot }) => { - applyAuthSuccessState(context, { - githubStatus, - message: `Saved GitHub token (${label}).`, - snapshot - }) + failureMessage: githubLoginFailureMessage, + markers: githubLoginStreamMarkers, + onSuccess: () => { context.reloadDashboard() - } + }, + runStream: loginGithubStream, + startMessage: "GitHub OAuth запущен. Следуй инструкциям в Output.", + successMessage: (label) => `Saved GitHub token (${label}).`, + values }) } diff --git a/packages/app/src/web/actions-terminal-auth.ts b/packages/app/src/web/actions-terminal-auth.ts new file mode 100644 index 00000000..967d830c --- /dev/null +++ b/packages/app/src/web/actions-terminal-auth.ts @@ -0,0 +1,32 @@ +import { type TerminalAuthFlow, terminalAuthTitle } from "../docker-git/menu-auth-shared.js" +import { type BrowserActionContext, defaultLabel, nullableValue, withBusy } from "./actions-shared.js" +import { createAuthTerminalSession } from "./api.js" + +export const runTerminalOnlyAuthAction = ( + action: TerminalAuthFlow, + values: Readonly>, + context: BrowserActionContext +) => { + const provider = terminalAuthTitle(action) + const label = nullableValue(values["label"]) + const sessionLabel = defaultLabel(values["label"]) + withBusy({ + context, + effect: createAuthTerminalSession(action, label), + label: provider, + onSuccess: (session) => { + context.setActionPrompt(null) + context.addTerminalSession({ + closePath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}`, + exitMessage: `${provider} finished (${sessionLabel}).`, + header: provider, + pendingDeleteMessage: `${provider} was closed before attach.`, + readyMessage: `${provider} started (${sessionLabel}).`, + session, + subtitle: session.sshCommand, + websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` + }) + context.setMessage(`${provider} is opening in the embedded terminal.`) + } + }) +} diff --git a/packages/app/src/web/actions-visible-auth-stream.ts b/packages/app/src/web/actions-visible-auth-stream.ts new file mode 100644 index 00000000..82a8a155 --- /dev/null +++ b/packages/app/src/web/actions-visible-auth-stream.ts @@ -0,0 +1,71 @@ +import { Effect } from "effect" + +import { + authStreamMarkerExitCode, + type AuthStreamMarkers, + authStreamSucceeded, + makeVisibleAuthStreamWriter +} from "../shared/auth-stream-markers.js" +import { + appendOutputChunk, + applyAuthSuccessState, + type BrowserActionContext, + defaultLabel, + nullableValue, + withBusy +} from "./actions-shared.js" +import { loadAuthSnapshot, loadGithubStatus } from "./api.js" + +type AuthStreamRunner = ( + label: string | null, + onChunk: (chunk: string) => void +) => Effect.Effect + +type VisibleAuthStreamMutationConfig = { + readonly busyLabel: string + readonly context: BrowserActionContext + readonly failureMessage: (output: string, exitCode: string | null) => string + readonly markers: AuthStreamMarkers + readonly onSuccess?: () => void + readonly runStream: AuthStreamRunner + readonly startMessage: string + readonly successMessage: (label: string) => string + readonly values: Readonly> +} + +export const runVisibleAuthStreamMutation = (config: VisibleAuthStreamMutationConfig) => { + const label = defaultLabel(config.values["label"]) + const writer = makeVisibleAuthStreamWriter(config.markers, (chunk) => { + appendOutputChunk(config.context, chunk) + }) + config.context.setOutput("") + config.context.setMessage(config.startMessage) + withBusy({ + context: config.context, + effect: config.runStream(nullableValue(config.values["label"]), writer.writeChunk).pipe( + Effect.ensuring(Effect.sync(writer.flushVisiblePending)), + Effect.flatMap((output) => + authStreamSucceeded(output, config.markers) + ? Effect.all({ + githubStatus: loadGithubStatus(), + snapshot: loadAuthSnapshot() + }) + : Effect.fail(config.failureMessage( + output, + authStreamMarkerExitCode(output, config.markers) + )) + ) + ), + label: config.busyLabel, + onSuccess: ({ githubStatus, snapshot }) => { + applyAuthSuccessState(config.context, { + githubStatus, + message: config.successMessage(label), + snapshot + }) + if (config.onSuccess !== undefined) { + config.onSuccess() + } + } + }) +} diff --git a/packages/app/tests/docker-git/actions-auth.test.ts b/packages/app/tests/docker-git/actions-auth.test.ts index 19a371ff..def35dc9 100644 --- a/packages/app/tests/docker-git/actions-auth.test.ts +++ b/packages/app/tests/docker-git/actions-auth.test.ts @@ -1,3 +1,4 @@ +/* jscpd:ignore-start */ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { beforeEach, vi } from "vitest" @@ -120,3 +121,4 @@ describe("web auth actions", () => { expect(loginCodexStreamMock).toHaveBeenCalledWith(null, expect.any(Function)) })) }) +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/actions-codex-oauth.test.ts b/packages/app/tests/docker-git/actions-codex-oauth.test.ts index 2030e246..7818a54d 100644 --- a/packages/app/tests/docker-git/actions-codex-oauth.test.ts +++ b/packages/app/tests/docker-git/actions-codex-oauth.test.ts @@ -1,3 +1,4 @@ +/* jscpd:ignore-start */ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { vi } from "vitest" @@ -68,3 +69,4 @@ describe("web Codex OAuth action", () => { expect(setMessage).toHaveBeenLastCalledWith("Saved Codex login (default).") })) }) +/* jscpd:ignore-end */ From a1056419d49511dd9f03aa15fc4164bc2c71edbc Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 16:16:51 +0000 Subject: [PATCH 3/7] fix(auth): address review feedback --- .../api/src/services/auth-account-counts.ts | 24 +++++-- packages/api/tests/auth-menu.test.ts | 16 ++++- packages/app/src/docker-git/api-auth-codec.ts | 2 + .../app/src/docker-git/menu-auth-helpers.ts | 44 ++++++------ packages/app/src/web/actions-codex-oauth.ts | 2 +- packages/app/src/web/actions-github-oauth.ts | 2 +- .../docker-git/actions-codex-oauth.test.ts | 5 ++ .../docker-git/actions-github-oauth.test.ts | 5 ++ .../tests/docker-git/api-auth-codec.test.ts | 34 ++++++++++ .../docker-git/menu-auth-helpers.test.ts | 68 +++++++++++++++++++ packages/lib/src/usecases/auth-grok-oauth.ts | 2 +- 11 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 packages/app/tests/docker-git/api-auth-codec.test.ts create mode 100644 packages/app/tests/docker-git/menu-auth-helpers.test.ts diff --git a/packages/api/src/services/auth-account-counts.ts b/packages/api/src/services/auth-account-counts.ts index ee50b47a..df025e71 100644 --- a/packages/api/src/services/auth-account-counts.ts +++ b/packages/api/src/services/auth-account-counts.ts @@ -13,6 +13,8 @@ type HasCredentials = ( const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) const grokEnvApiKeyNames: ReadonlyArray = ["GROK_DEPLOYMENT_KEY", "GROK_API_KEY", "XAI_API_KEY"] +const credentialCount = (connected: boolean): number => connected ? 1 : 0 + const hasFileAtPath = ( fs: FileSystem.FileSystem, filePath: string @@ -235,7 +237,12 @@ export const countCodexCredentialAccounts = ( return 0 } - let count = yield* _(hasCodexAccountCredentials(fs, root), Effect.map((connected) => connected ? 1 : 0)) + let count = yield* _( + hasCodexAccountCredentials(fs, root).pipe( + Effect.orElseSucceed(() => false), + Effect.map((connected) => credentialCount(connected)) + ) + ) const entries = yield* _(fs.readDirectory(root)) for (const entry of entries) { if (ignoredAuthAccountEntries.has(entry)) { @@ -243,8 +250,8 @@ export const countCodexCredentialAccounts = ( } const accountPath = path.join(root, entry) - const info = yield* _(fs.stat(accountPath)) - if (info.type !== "Directory") { + const info = yield* _(fs.stat(accountPath), Effect.orElseSucceed(() => null)) + if (info === null || info.type !== "Directory") { continue } @@ -268,16 +275,21 @@ export const countAuthCredentialAccounts = ( return 0 } + let count = yield* _( + hasCredentials(fs, root).pipe( + Effect.orElseSucceed(() => false), + Effect.map((connected) => credentialCount(connected)) + ) + ) const entries = yield* _(fs.readDirectory(root)) - let count = 0 for (const entry of entries) { if (ignoredAuthAccountEntries.has(entry)) { continue } const accountPath = path.join(root, entry) - const info = yield* _(fs.stat(accountPath)) - if (info.type !== "Directory") { + const info = yield* _(fs.stat(accountPath), Effect.orElseSucceed(() => null)) + if (info === null || info.type !== "Directory") { continue } diff --git a/packages/api/tests/auth-menu.test.ts b/packages/api/tests/auth-menu.test.ts index 218190e6..a527057d 100644 --- a/packages/api/tests/auth-menu.test.ts +++ b/packages/api/tests/auth-menu.test.ts @@ -64,14 +64,24 @@ describe("auth menu service", () => { yield* _(fs.makeDirectory(path.join(authRoot, "codex", ".image"), { recursive: true })) yield* _(fs.makeDirectory(path.join(authRoot, "grok", ".image"), { recursive: true })) + yield* _(fs.writeFileString(path.join(authRoot, "claude", ".oauth-token"), "root-claude-oauth\n")) yield* _(fs.makeDirectory(path.join(authRoot, "claude", "live"), { recursive: true })) yield* _(fs.writeFileString(path.join(authRoot, "claude", "live", ".oauth-token"), "claude-oauth\n")) yield* _(fs.makeDirectory(path.join(authRoot, "codex"), { recursive: true })) yield* _(fs.writeFileString(path.join(authRoot, "codex", "auth.json"), "{\"tokens\":{\"account_id\":\"default\"}}\n")) yield* _(fs.makeDirectory(path.join(authRoot, "codex", "work"), { recursive: true })) yield* _(fs.writeFileString(path.join(authRoot, "codex", "work", "auth.json"), "{\"tokens\":{\"account_id\":\"work\"}}\n")) + yield* _(fs.symlink(path.join(authRoot, "missing-gemini-account"), path.join(authRoot, "gemini", "broken"))) + yield* _(fs.writeFileString(path.join(authRoot, "gemini", ".api-key"), "root-gemini-key\n")) yield* _(fs.makeDirectory(path.join(authRoot, "gemini", "live"), { recursive: true })) yield* _(fs.writeFileString(path.join(authRoot, "gemini", "live", ".api-key"), "gemini-key\n")) + yield* _(fs.makeDirectory(path.join(authRoot, "grok", ".grok"), { recursive: true })) + yield* _( + fs.writeFileString( + path.join(authRoot, "grok", ".grok", "auth.json"), + `${JSON.stringify({ [grokOidcAuthScope]: { key: "root-xai-oauth" } })}\n` + ) + ) yield* _(fs.makeDirectory(path.join(authRoot, "grok", "live", ".grok"), { recursive: true })) yield* _( fs.writeFileString( @@ -93,10 +103,10 @@ describe("auth menu service", () => { ) const snapshot = yield* _(withProjectsRoot(projectsRoot, service.readAuthMenuSnapshot())) - expect(snapshot.claudeAuthEntries).toBe(1) + expect(snapshot.claudeAuthEntries).toBe(2) expect(snapshot.codexAuthEntries).toBe(2) - expect(snapshot.geminiAuthEntries).toBe(1) - expect(snapshot.grokAuthEntries).toBe(1) + expect(snapshot.geminiAuthEntries).toBe(2) + expect(snapshot.grokAuthEntries).toBe(2) }) ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/app/src/docker-git/api-auth-codec.ts b/packages/app/src/docker-git/api-auth-codec.ts index be920541..4f0d2c7a 100644 --- a/packages/app/src/docker-git/api-auth-codec.ts +++ b/packages/app/src/docker-git/api-auth-codec.ts @@ -79,6 +79,7 @@ const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | n const requiredValues = [ snapshot.globalEnvPath, snapshot.claudeAuthPath, + snapshot.codexAuthPath, snapshot.geminiAuthPath, snapshot.grokAuthPath, snapshot.totalEntries, @@ -86,6 +87,7 @@ const decodeRequiredAuthSnapshot = (snapshot: RawAuthSnapshot): AuthSnapshot | n snapshot.gitTokenEntries, snapshot.gitUserEntries, snapshot.claudeAuthEntries, + snapshot.codexAuthEntries, snapshot.geminiAuthEntries, snapshot.grokAuthEntries ] diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts index 91135403..515d8fe9 100644 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ b/packages/app/src/docker-git/menu-auth-helpers.ts @@ -19,6 +19,8 @@ type CredentialDirectoryCounterInput = { const ignoredAuthAccountEntries: ReadonlySet = new Set([".image"]) +const credentialCount = (connected: boolean): number => connected ? 1 : 0 + export const hasCodexAccountCredentials = ( fs: FileSystem.FileSystem, accountPath: string @@ -38,8 +40,8 @@ const countCredentialDirectories = ({ continue } const accountPath = path.join(root, entry) - const info = yield* _(fs.stat(accountPath)) - if (info.type !== "Directory") { + const info = yield* _(fs.stat(accountPath), Effect.orElseSucceed(() => null)) + if (info === null || info.type !== "Directory") { continue } const connected = yield* _(hasCredentials(fs, accountPath), Effect.orElseSucceed(() => false)) @@ -54,11 +56,20 @@ const countExistingCredentialDirectories = ( input: CredentialDirectoryCounterInput ): Effect.Effect => input.fs.exists(input.root).pipe( - Effect.flatMap((exists) => - exists - ? countCredentialDirectories(input) - : Effect.succeed(0) - ) + Effect.flatMap((exists) => { + if (!exists) { + return Effect.succeed(0) + } + return Effect.all({ + directoryCount: countCredentialDirectories(input), + rootCount: input.hasCredentials(input.fs, input.root).pipe( + Effect.orElseSucceed(() => false), + Effect.map((connected) => credentialCount(connected)) + ) + }).pipe( + Effect.map(({ directoryCount, rootCount }) => rootCount + directoryCount) + ) + }) ) export const countAuthCredentialAccounts = ( @@ -73,21 +84,4 @@ export const countCodexCredentialAccounts = ( path: Path.Path, root: string ): Effect.Effect => - fs.exists(root).pipe( - Effect.flatMap((exists) => { - if (!exists) { - return Effect.succeed(0) - } - return Effect.all({ - directoryCount: countCredentialDirectories({ - fs, - hasCredentials: hasCodexAccountCredentials, - path, - root - }), - rootConnected: hasCodexAccountCredentials(fs, root).pipe(Effect.orElseSucceed(() => false)) - }).pipe( - Effect.map(({ directoryCount, rootConnected }) => directoryCount + (rootConnected ? 1 : 0)) - ) - }) - ) + countExistingCredentialDirectories({ fs, hasCredentials: hasCodexAccountCredentials, path, root }) diff --git a/packages/app/src/web/actions-codex-oauth.ts b/packages/app/src/web/actions-codex-oauth.ts index dc3f3038..cb46a359 100644 --- a/packages/app/src/web/actions-codex-oauth.ts +++ b/packages/app/src/web/actions-codex-oauth.ts @@ -21,7 +21,7 @@ export const runCodexOauthMutation = ( failureMessage: codexLoginFailureMessage, markers: codexLoginStreamMarkers, runStream: loginCodexStream, - startMessage: "Codex OAuth запущен. Следуй инструкциям в Output.", + startMessage: "Codex OAuth started. Follow the instructions in Output.", successMessage: (label) => `Saved Codex login (${label}).`, values }) diff --git a/packages/app/src/web/actions-github-oauth.ts b/packages/app/src/web/actions-github-oauth.ts index cbefe1d7..b7234929 100644 --- a/packages/app/src/web/actions-github-oauth.ts +++ b/packages/app/src/web/actions-github-oauth.ts @@ -16,7 +16,7 @@ export const runGithubOauthMutation = ( context.reloadDashboard() }, runStream: loginGithubStream, - startMessage: "GitHub OAuth запущен. Следуй инструкциям в Output.", + startMessage: "GitHub OAuth started. Follow the instructions in Output.", successMessage: (label) => `Saved GitHub token (${label}).`, values }) diff --git a/packages/app/tests/docker-git/actions-codex-oauth.test.ts b/packages/app/tests/docker-git/actions-codex-oauth.test.ts index 7818a54d..dd902d37 100644 --- a/packages/app/tests/docker-git/actions-codex-oauth.test.ts +++ b/packages/app/tests/docker-git/actions-codex-oauth.test.ts @@ -59,6 +59,11 @@ describe("web Codex OAuth action", () => { runCodexOauthMutation({ label: "" }, context) + expect(setMessage).toHaveBeenNthCalledWith( + 1, + "Codex OAuth started. Follow the instructions in Output." + ) + yield* _(waitForAssertion(() => { expect(context.setAuthSnapshot).toHaveBeenCalledWith(authSnapshot) })) diff --git a/packages/app/tests/docker-git/actions-github-oauth.test.ts b/packages/app/tests/docker-git/actions-github-oauth.test.ts index a4c7be5b..68249229 100644 --- a/packages/app/tests/docker-git/actions-github-oauth.test.ts +++ b/packages/app/tests/docker-git/actions-github-oauth.test.ts @@ -67,6 +67,11 @@ describe("web GitHub OAuth action", () => { runGithubOauthMutation({ label: "" }, context) + expect(setMessage).toHaveBeenNthCalledWith( + 1, + "GitHub OAuth started. Follow the instructions in Output." + ) + yield* _(waitForAssertion(() => { expect(reloadDashboard).toHaveBeenCalledTimes(1) })) diff --git a/packages/app/tests/docker-git/api-auth-codec.test.ts b/packages/app/tests/docker-git/api-auth-codec.test.ts new file mode 100644 index 00000000..5c8a9811 --- /dev/null +++ b/packages/app/tests/docker-git/api-auth-codec.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/vitest" + +import { decodeAuthSnapshot } from "../../src/docker-git/api-auth-codec.js" +import type { JsonValue } from "../../src/docker-git/api-json.js" + +const fullAuthSnapshot = { + claudeAuthEntries: 1, + claudeAuthPath: "/auth/claude", + codexAuthEntries: 2, + codexAuthPath: "/auth/codex", + geminiAuthEntries: 3, + geminiAuthPath: "/auth/gemini", + gitTokenEntries: 4, + gitUserEntries: 5, + githubTokenEntries: 6, + globalEnvPath: "/env/global.env", + grokAuthEntries: 7, + grokAuthPath: "/auth/grok", + totalEntries: 8 +} satisfies JsonValue + +describe("api auth codec", () => { + it("requires Codex fields in auth snapshots", () => { + const missingPath = { ...fullAuthSnapshot, codexAuthPath: null } satisfies JsonValue + const missingEntries = { ...fullAuthSnapshot, codexAuthEntries: null } satisfies JsonValue + + expect(decodeAuthSnapshot({ snapshot: missingPath })).toBe(null) + expect(decodeAuthSnapshot({ snapshot: missingEntries })).toBe(null) + }) + + it("decodes complete auth snapshots", () => { + expect(decodeAuthSnapshot({ snapshot: fullAuthSnapshot })).toEqual(fullAuthSnapshot) + }) +}) diff --git a/packages/app/tests/docker-git/menu-auth-helpers.test.ts b/packages/app/tests/docker-git/menu-auth-helpers.test.ts new file mode 100644 index 00000000..f6cebe29 --- /dev/null +++ b/packages/app/tests/docker-git/menu-auth-helpers.test.ts @@ -0,0 +1,68 @@ +/* jscpd:ignore-start */ +import { NodeContext } from "@effect/platform-node" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import type * as Scope from "effect/Scope" + +import { countAuthCredentialAccounts, countCodexCredentialAccounts } from "../../src/docker-git/menu-auth-helpers.js" + +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-auth-helpers-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const hasMarkerCredentials = ( + fs: FileSystem.FileSystem, + accountPath: string +): Effect.Effect => + fs.readFileString(`${accountPath}/.token`).pipe( + Effect.map((content) => content.trim().length > 0) + ) + +describe("menu auth helpers", () => { + it.effect("counts root credentials and skips broken directory entries", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + yield* _(fs.makeDirectory(path.join(root, "work"), { recursive: true })) + yield* _(fs.writeFileString(path.join(root, ".token"), "root-token\n")) + yield* _(fs.writeFileString(path.join(root, "work", ".token"), "work-token\n")) + yield* _(fs.symlink(path.join(root, "missing-account"), path.join(root, "broken"))) + + const count = yield* _(countAuthCredentialAccounts(fs, path, root, hasMarkerCredentials)) + + expect(count).toBe(2) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("counts root and labeled Codex credentials", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + yield* _(fs.makeDirectory(path.join(root, "work"), { recursive: true })) + yield* _(fs.writeFileString(path.join(root, "auth.json"), "{}\n")) + yield* _(fs.writeFileString(path.join(root, "work", "auth.json"), "{}\n")) + yield* _(fs.symlink(path.join(root, "missing-account"), path.join(root, "broken"))) + + const count = yield* _(countCodexCredentialAccounts(fs, path, root)) + + expect(count).toBe(2) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) +/* jscpd:ignore-end */ diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index 0706417e..4d2a1f75 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -127,7 +127,7 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. * @param options Auth container image and in-container home path. - * @returns Effect that completes after device authorization writes credentials and permissions are normalized. + * @returns Effect that completes after OAuth/browser flow writes credentials and permissions are normalized. * @pure false * @effect CommandExecutor; invokes Docker and writes credentials under the selected account path. * @invariant successful completion leaves credentials scoped to accountPath and not to project source files. From 68cccade9689210fcfe547f7f9c6aada74c785ee Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 16:55:23 +0000 Subject: [PATCH 4/7] docs(auth): document grok oauth flow --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4bc7a96f..daf36484 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,13 @@ 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`. +and runs the interactive `grok login` OAuth/browser flow inside the auth +container. Open the sign-in URL printed by the CLI, complete browser consent, +and, if the local callback cannot connect, paste the returned callback URL into +the prompt. On success docker-git writes the normalized Grok credentials under +the selected Grok account label and fixes account-file permissions. 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 пример From 7583c9b9a1bde9e053138337f8e858689be1b022 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 17:23:43 +0000 Subject: [PATCH 5/7] docs(auth): clarify grok oauth docs --- README.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index daf36484..05d85316 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,16 @@ 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 runs the interactive `grok login` OAuth/browser flow inside the auth -container. Open the sign-in URL printed by the CLI, complete browser consent, -and, if the local callback cannot connect, paste the returned callback URL into -the prompt. On success docker-git writes the normalized Grok credentials under -the selected Grok account label and fixes account-file permissions. API-key auth -can also be stored under the selected Grok account label via -`GROK_DEPLOYMENT_KEY`, `GROK_API_KEY`, or `XAI_API_KEY`. +Поддержка Grok использует официальный установщик xAI CLI из `https://x.ai/cli/install.sh` +и запускает интерактивный OAuth/браузерный поток `grok login` внутри auth-контейнера. +Откройте URL входа, который вывел CLI, завершите подтверждение в браузере и, если +локальный callback не сможет подключиться, вставьте возвращённый callback URL в prompt. +При успехе docker-git записывает нормализованные Grok credentials под выбранным +Grok account label и исправляет права на account-file. API-key авторизацию также +можно сохранить под выбранным Grok account label через переменные окружения +`GROK_DEPLOYMENT_KEY`, `GROK_API_KEY` или `XAI_API_KEY`. Если задано несколько +переменных, используется детерминированный порядок переопределения: +сначала `GROK_DEPLOYMENT_KEY`, затем `GROK_API_KEY`, затем `XAI_API_KEY`. ## CLI пример From 6aa93b93181aa5529e84bcb4ee1b0995c80819f9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 17:32:49 +0000 Subject: [PATCH 6/7] docs(auth): simplify auth quickstart --- README.md | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/README.md b/README.md index 05d85316..fdc79cf3 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,7 @@ docker-git auth claude login --web docker-git auth grok login --web ``` -Поддержка Grok использует официальный установщик xAI CLI из `https://x.ai/cli/install.sh` -и запускает интерактивный OAuth/браузерный поток `grok login` внутри auth-контейнера. -Откройте URL входа, который вывел CLI, завершите подтверждение в браузере и, если -локальный callback не сможет подключиться, вставьте возвращённый callback URL в prompt. -При успехе docker-git записывает нормализованные Grok credentials под выбранным -Grok account label и исправляет права на account-file. API-key авторизацию также -можно сохранить под выбранным Grok account label через переменные окружения -`GROK_DEPLOYMENT_KEY`, `GROK_API_KEY` или `XAI_API_KEY`. Если задано несколько -переменных, используется детерминированный порядок переопределения: -сначала `GROK_DEPLOYMENT_KEY`, затем `GROK_API_KEY`, затем `XAI_API_KEY`. +Команды `login` запускают интерактивную OAuth-авторизацию выбранного провайдера. ## CLI пример From fca64b3976056f31c372163bbc2748ad104304c7 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 20 May 2026 18:04:57 +0000 Subject: [PATCH 7/7] docs(auth): keep auth quickstart concise --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index fdc79cf3..5db85c85 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,6 @@ docker-git auth claude login --web docker-git auth grok login --web ``` -Команды `login` запускают интерактивную OAuth-авторизацию выбранного провайдера. - ## CLI пример Можно передавать ссылку на репозиторий, ветку (`/tree/...`), issue или PR.