|
| 1 | +import type { PlatformError } from "@effect/platform/Error" |
| 2 | +import * as ParseResult from "@effect/schema/ParseResult" |
| 3 | +import * as Schema from "@effect/schema/Schema" |
| 4 | +import { Effect, Either } from "effect" |
| 5 | + |
| 6 | +type CopyDecision = "skip" | "copy" |
| 7 | +type JsonPrimitive = boolean | number | string | null |
| 8 | +type JsonValue = JsonPrimitive | JsonRecord | ReadonlyArray<JsonValue> |
| 9 | +type JsonRecord = Readonly<{ [key: string]: JsonValue }> |
| 10 | + |
| 11 | +const JsonValueSchema: Schema.Schema<JsonValue> = Schema.suspend(() => |
| 12 | + Schema.Union( |
| 13 | + Schema.Null, |
| 14 | + Schema.Boolean, |
| 15 | + Schema.String, |
| 16 | + Schema.JsonNumber, |
| 17 | + Schema.Array(JsonValueSchema), |
| 18 | + Schema.Record({ key: Schema.String, value: JsonValueSchema }) |
| 19 | + ) |
| 20 | +) |
| 21 | + |
| 22 | +const JsonRecordSchema: Schema.Schema<JsonRecord> = Schema.Record({ |
| 23 | + key: Schema.String, |
| 24 | + value: JsonValueSchema |
| 25 | +}) |
| 26 | + |
| 27 | +const JsonRecordFromStringSchema = Schema.parseJson(JsonRecordSchema) |
| 28 | +const defaultEnvContents = "# docker-git env\n# KEY=value\n" |
| 29 | +const codexConfigMarker = "# docker-git codex config" |
| 30 | + |
| 31 | +// CHANGE: enable web search tool in default Codex config (top-level) |
| 32 | +// WHY: avoid deprecated legacy flags and keep config minimal |
| 33 | +// QUOTE(ТЗ): "да убери легаси" |
| 34 | +// REF: user-request-2026-02-05-remove-legacy-web-search |
| 35 | +// SOURCE: n/a |
| 36 | +// FORMAT THEOREM: ∀c: config(c) -> web_search(c)="live" |
| 37 | +// PURITY: CORE |
| 38 | +// EFFECT: n/a |
| 39 | +// INVARIANT: default config stays deterministic |
| 40 | +// COMPLEXITY: O(1) |
| 41 | +export const defaultCodexConfig = [ |
| 42 | + "# docker-git codex config", |
| 43 | + "model = \"gpt-5.3-codex\"", |
| 44 | + "model_reasoning_effort = \"xhigh\"", |
| 45 | + "personality = \"pragmatic\"", |
| 46 | + "", |
| 47 | + "approval_policy = \"never\"", |
| 48 | + "sandbox_mode = \"danger-full-access\"", |
| 49 | + "web_search = \"live\"", |
| 50 | + "", |
| 51 | + "[features]", |
| 52 | + "shell_snapshot = true", |
| 53 | + "multi_agent = true", |
| 54 | + "apps = true", |
| 55 | + "shell_tool = true" |
| 56 | +].join("\n") |
| 57 | + |
| 58 | +export const resolvePathFromBase = ( |
| 59 | + path: { |
| 60 | + readonly isAbsolute: (targetPath: string) => boolean |
| 61 | + readonly resolve: (...parts: ReadonlyArray<string>) => string |
| 62 | + }, |
| 63 | + baseDir: string, |
| 64 | + targetPath: string |
| 65 | +): string => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) |
| 66 | + |
| 67 | +const isPermissionDeniedSystemError = (error: PlatformError): boolean => |
| 68 | + error._tag === "SystemError" && error.reason === "PermissionDenied" |
| 69 | + |
| 70 | +export const skipCodexConfigPermissionDenied = ( |
| 71 | + configPath: string, |
| 72 | + error: PlatformError |
| 73 | +): Effect.Effect<void, PlatformError> => |
| 74 | + isPermissionDeniedSystemError(error) |
| 75 | + ? Effect.logWarning( |
| 76 | + `Skipped Codex config sync at ${configPath}: permission denied (${error.description ?? "no details"}).` |
| 77 | + ) |
| 78 | + : Effect.fail(error) |
| 79 | + |
| 80 | +const normalizeConfigText = (text: string): string => |
| 81 | + text |
| 82 | + .replaceAll("\r\n", "\n") |
| 83 | + .trim() |
| 84 | + |
| 85 | +export const shouldRewriteDockerGitCodexConfig = (existing: string): boolean => { |
| 86 | + const normalized = normalizeConfigText(existing) |
| 87 | + if (normalized.length === 0) { |
| 88 | + return true |
| 89 | + } |
| 90 | + if (!normalized.startsWith(codexConfigMarker)) { |
| 91 | + return false |
| 92 | + } |
| 93 | + return normalized !== normalizeConfigText(defaultCodexConfig) |
| 94 | +} |
| 95 | + |
| 96 | +export const shouldCopyEnv = (sourceText: string, targetText: string): CopyDecision => { |
| 97 | + if (sourceText.trim().length === 0) { |
| 98 | + return "skip" |
| 99 | + } |
| 100 | + if (targetText.trim().length === 0) { |
| 101 | + return "copy" |
| 102 | + } |
| 103 | + if (targetText.trim() === defaultEnvContents.trim() && sourceText.trim() !== defaultEnvContents.trim()) { |
| 104 | + return "copy" |
| 105 | + } |
| 106 | + return "skip" |
| 107 | +} |
| 108 | + |
| 109 | +export const parseJsonRecord = (text: string): Effect.Effect<JsonRecord | null> => |
| 110 | + Either.match(ParseResult.decodeUnknownEither(JsonRecordFromStringSchema)(text), { |
| 111 | + onLeft: () => Effect.succeed(null), |
| 112 | + onRight: (record) => Effect.succeed(record) |
| 113 | + }) |
| 114 | + |
| 115 | +export const hasClaudeOauthAccount = (record: JsonRecord | null): boolean => |
| 116 | + record !== null && typeof record["oauthAccount"] === "object" && record["oauthAccount"] !== null |
| 117 | + |
| 118 | +export const hasClaudeCredentials = (record: JsonRecord | null): boolean => |
| 119 | + record !== null && typeof record["claudeAiOauth"] === "object" && record["claudeAiOauth"] !== null |
| 120 | + |
| 121 | +export const isGithubTokenKey = (key: string): boolean => |
| 122 | + key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__") |
| 123 | + |
| 124 | +export type AuthPaths = { |
| 125 | + readonly envGlobalPath: string |
| 126 | + readonly envProjectPath: string |
| 127 | + readonly codexAuthPath: string |
| 128 | +} |
| 129 | + |
| 130 | +export type AuthSyncSpec = { |
| 131 | + readonly sourceBase: string |
| 132 | + readonly targetBase: string |
| 133 | + readonly source: AuthPaths |
| 134 | + readonly target: AuthPaths |
| 135 | +} |
| 136 | + |
| 137 | +export type LegacyOrchPaths = AuthPaths & { |
| 138 | + readonly ghAuthPath: string |
| 139 | + readonly claudeAuthPath: string |
| 140 | +} |
0 commit comments