Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions packages/lib/src/core/templates-entrypoint/claude.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import type { TemplateConfig } from "../domain.js"

const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude`
const claudeHomeContainerPath = (sshUser: string): string => `/home/${sshUser}/.claude`

const renderClaudeAuthConfig = (config: TemplateConfig): string =>
String
.raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude)
const claudeAuthConfigTemplate = String
.raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude)
CLAUDE_LABEL_RAW="$CLAUDE_AUTH_LABEL"
if [[ -z "$CLAUDE_LABEL_RAW" ]]; then
CLAUDE_LABEL_RAW="default"
Expand All @@ -18,7 +16,7 @@ if [[ -z "$CLAUDE_LABEL_NORM" ]]; then
CLAUDE_LABEL_NORM="default"
fi

CLAUDE_AUTH_ROOT="${claudeAuthRootContainerPath(config.sshUser)}"
CLAUDE_AUTH_ROOT="__CLAUDE_AUTH_ROOT__"
CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT/$CLAUDE_LABEL_NORM"

# Backward compatibility: if default auth is stored directly under claude root, reuse it.
Expand All @@ -33,8 +31,8 @@ fi
export CLAUDE_CONFIG_DIR

mkdir -p "$CLAUDE_CONFIG_DIR" || true
CLAUDE_HOME_DIR="${claudeHomeContainerPath(config.sshUser)}"
CLAUDE_HOME_JSON="/home/${config.sshUser}/.claude.json"
CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__"
CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__"
mkdir -p "$CLAUDE_HOME_DIR" || true

docker_git_link_claude_file() {
Expand Down Expand Up @@ -86,6 +84,12 @@ docker_git_refresh_claude_oauth_token() {

docker_git_refresh_claude_oauth_token`

const renderClaudeAuthConfig = (config: TemplateConfig): string =>
claudeAuthConfigTemplate
.replaceAll("__CLAUDE_AUTH_ROOT__", claudeAuthRootContainerPath(config.sshUser))
.replaceAll("__CLAUDE_HOME_DIR__", `/home/${config.sshUser}/.claude`)
.replaceAll("__CLAUDE_HOME_JSON__", `/home/${config.sshUser}/.claude.json`)

const renderClaudeCliInstall = (): string =>
String.raw`# Claude Code: ensure CLI command exists (non-blocking startup self-heal)
docker_git_ensure_claude_cli() {
Expand Down Expand Up @@ -186,7 +190,8 @@ NODE
docker_git_sync_claude_playwright_mcp
chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true`

const entrypointClaudeGlobalPromptTemplate = String.raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code)
const entrypointClaudeGlobalPromptTemplate = String
.raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code)
CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md"
CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}"
CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository"
Expand Down
13 changes: 9 additions & 4 deletions packages/lib/src/shell/docker-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,18 @@ export type DockerAuthSpec = {
readonly interactive: boolean
}

const resolveDefaultDockerUser = (): string | null => {
const getUid = (process as { readonly getuid?: () => number }).getuid
const getGid = (process as { readonly getgid?: () => number }).getgid
export const resolveDefaultDockerUser = (): string | null => {
const getUid = Reflect.get(process, "getuid")
const getGid = Reflect.get(process, "getgid")
if (typeof getUid !== "function" || typeof getGid !== "function") {
return null
}
return `${getUid()}:${getGid()}`
const uid = getUid.call(process)
const gid = getGid.call(process)
if (typeof uid !== "number" || typeof gid !== "number") {
return null
}
return `${uid}:${gid}`
}

const appendEnvArgs = (base: Array<string>, env: string | ReadonlyArray<string>) => {
Expand Down
15 changes: 7 additions & 8 deletions packages/lib/src/usecases/actions/prepare-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,10 @@ export const migrateProjectOrchLayout = (
globalConfig: CreateCommand["config"],
resolveRootPath: (value: string) => string
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
migrateLegacyOrchLayout(
baseDir,
globalConfig.envGlobalPath,
globalConfig.envProjectPath,
globalConfig.codexAuthPath,
resolveRootPath(".docker-git/.orch/auth/gh"),
resolveRootPath(".docker-git/.orch/auth/claude")
)
migrateLegacyOrchLayout(baseDir, {
envGlobalPath: globalConfig.envGlobalPath,
envProjectPath: globalConfig.envProjectPath,
codexAuthPath: globalConfig.codexAuthPath,
ghAuthPath: resolveRootPath(".docker-git/.orch/auth/gh"),
claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude")
})
8 changes: 4 additions & 4 deletions packages/lib/src/usecases/auth-claude-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"
import type * as Scope from "effect/Scope"
import * as Stream from "effect/Stream"

import { resolveDefaultDockerUser } from "../shell/docker-auth.js"
import { AuthError, CommandFailedError } from "../shell/errors.js"

const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN"
Expand Down Expand Up @@ -142,10 +143,9 @@ const buildDockerSetupTokenSpec = (

const buildDockerSetupTokenArgs = (spec: DockerSetupTokenSpec): ReadonlyArray<string> => {
const base: Array<string> = ["run", "--rm", "-i", "-t", "-v", `${spec.hostPath}:${spec.containerPath}`]
const getUid = (process as { readonly getuid?: () => number }).getuid
const getGid = (process as { readonly getgid?: () => number }).getgid
if (typeof getUid === "function" && typeof getGid === "function") {
base.push("--user", `${getUid()}:${getGid()}`)
const dockerUser = resolveDefaultDockerUser()
if (dockerUser !== null) {
base.push("--user", dockerUser)
}
for (const entry of spec.env) {
const trimmed = entry.trim()
Expand Down
15 changes: 7 additions & 8 deletions packages/lib/src/usecases/auth-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,13 @@ const buildClaudeAuthEnv = (
const ensureClaudeOrchLayout = (
cwd: string
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
migrateLegacyOrchLayout(
cwd,
defaultTemplateConfig.envGlobalPath,
defaultTemplateConfig.envProjectPath,
defaultTemplateConfig.codexAuthPath,
".docker-git/.orch/auth/gh",
".docker-git/.orch/auth/claude"
)
migrateLegacyOrchLayout(cwd, {
envGlobalPath: defaultTemplateConfig.envGlobalPath,
envProjectPath: defaultTemplateConfig.envProjectPath,
codexAuthPath: defaultTemplateConfig.codexAuthPath,
ghAuthPath: ".docker-git/.orch/auth/gh",
claudeAuthPath: ".docker-git/.orch/auth/claude"
})

const renderClaudeDockerfile = (): string =>
String.raw`FROM ubuntu:24.04
Expand Down
13 changes: 6 additions & 7 deletions packages/lib/src/usecases/auth-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ const ensureCodexOrchLayout = (
cwd: string,
codexAuthPath: string
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
migrateLegacyOrchLayout(
cwd,
defaultTemplateConfig.envGlobalPath,
defaultTemplateConfig.envProjectPath,
migrateLegacyOrchLayout(cwd, {
envGlobalPath: defaultTemplateConfig.envGlobalPath,
envProjectPath: defaultTemplateConfig.envProjectPath,
codexAuthPath,
".docker-git/.orch/auth/gh",
".docker-git/.orch/auth/claude"
)
ghAuthPath: ".docker-git/.orch/auth/gh",
claudeAuthPath: ".docker-git/.orch/auth/claude"
})

const renderCodexDockerfile = (): string =>
String.raw`FROM ubuntu:24.04
Expand Down
13 changes: 6 additions & 7 deletions packages/lib/src/usecases/auth-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,13 @@ const ensureGithubOrchLayout = (
cwd: string,
envGlobalPath: string
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
migrateLegacyOrchLayout(
cwd,
migrateLegacyOrchLayout(cwd, {
envGlobalPath,
defaultTemplateConfig.envProjectPath,
defaultTemplateConfig.codexAuthPath,
ghAuthRoot,
".docker-git/.orch/auth/claude"
)
envProjectPath: defaultTemplateConfig.envProjectPath,
codexAuthPath: defaultTemplateConfig.codexAuthPath,
ghAuthPath: ghAuthRoot,
claudeAuthPath: ".docker-git/.orch/auth/claude"
})

const normalizeGithubLabel = (value: string | null): string => {
const trimmed = value?.trim() ?? ""
Expand Down
140 changes: 140 additions & 0 deletions packages/lib/src/usecases/auth-sync-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { PlatformError } from "@effect/platform/Error"
import * as ParseResult from "@effect/schema/ParseResult"
import * as Schema from "@effect/schema/Schema"
import { Effect, Either } from "effect"

type CopyDecision = "skip" | "copy"
type JsonPrimitive = boolean | number | string | null
type JsonValue = JsonPrimitive | JsonRecord | ReadonlyArray<JsonValue>
type JsonRecord = Readonly<{ [key: string]: JsonValue }>

const JsonValueSchema: Schema.Schema<JsonValue> = 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<JsonRecord> = Schema.Record({
key: Schema.String,
value: JsonValueSchema
})

const JsonRecordFromStringSchema = Schema.parseJson(JsonRecordSchema)
const defaultEnvContents = "# docker-git env\n# KEY=value\n"
const codexConfigMarker = "# docker-git codex config"

// CHANGE: enable web search tool in default Codex config (top-level)
// WHY: avoid deprecated legacy flags and keep config minimal
// QUOTE(ТЗ): "да убери легаси"
// REF: user-request-2026-02-05-remove-legacy-web-search
// SOURCE: n/a
// FORMAT THEOREM: ∀c: config(c) -> web_search(c)="live"
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: default config stays deterministic
// COMPLEXITY: O(1)
export const defaultCodexConfig = [
"# docker-git codex config",
"model = \"gpt-5.3-codex\"",
"model_reasoning_effort = \"xhigh\"",
"personality = \"pragmatic\"",
"",
"approval_policy = \"never\"",
"sandbox_mode = \"danger-full-access\"",
"web_search = \"live\"",
"",
"[features]",
"shell_snapshot = true",
"multi_agent = true",
"apps = true",
"shell_tool = true"
].join("\n")

export const resolvePathFromBase = (
path: {
readonly isAbsolute: (targetPath: string) => boolean
readonly resolve: (...parts: ReadonlyArray<string>) => string
},
baseDir: string,
targetPath: string
): string => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath)

const isPermissionDeniedSystemError = (error: PlatformError): boolean =>
error._tag === "SystemError" && error.reason === "PermissionDenied"

export const skipCodexConfigPermissionDenied = (
configPath: string,
error: PlatformError
): Effect.Effect<void, PlatformError> =>
isPermissionDeniedSystemError(error)
? Effect.logWarning(
`Skipped Codex config sync at ${configPath}: permission denied (${error.description ?? "no details"}).`
)
: Effect.fail(error)

const normalizeConfigText = (text: string): string =>
text
.replaceAll("\r\n", "\n")
.trim()

export const shouldRewriteDockerGitCodexConfig = (existing: string): boolean => {
const normalized = normalizeConfigText(existing)
if (normalized.length === 0) {
return true
}
if (!normalized.startsWith(codexConfigMarker)) {
return false
}
return normalized !== normalizeConfigText(defaultCodexConfig)
}

export const shouldCopyEnv = (sourceText: string, targetText: string): CopyDecision => {
if (sourceText.trim().length === 0) {
return "skip"
}
if (targetText.trim().length === 0) {
return "copy"
}
if (targetText.trim() === defaultEnvContents.trim() && sourceText.trim() !== defaultEnvContents.trim()) {
return "copy"
}
return "skip"
}

export const parseJsonRecord = (text: string): Effect.Effect<JsonRecord | null> =>
Either.match(ParseResult.decodeUnknownEither(JsonRecordFromStringSchema)(text), {
onLeft: () => Effect.succeed(null),
onRight: (record) => Effect.succeed(record)
})

export const hasClaudeOauthAccount = (record: JsonRecord | null): boolean =>
record !== null && typeof record["oauthAccount"] === "object" && record["oauthAccount"] !== null

export const hasClaudeCredentials = (record: JsonRecord | null): boolean =>
record !== null && typeof record["claudeAiOauth"] === "object" && record["claudeAiOauth"] !== null

export const isGithubTokenKey = (key: string): boolean =>
key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__")

export type AuthPaths = {
readonly envGlobalPath: string
readonly envProjectPath: string
readonly codexAuthPath: string
}

export type AuthSyncSpec = {
readonly sourceBase: string
readonly targetBase: string
readonly source: AuthPaths
readonly target: AuthPaths
}

export type LegacyOrchPaths = AuthPaths & {
readonly ghAuthPath: string
readonly claudeAuthPath: string
}
Loading