|
| 1 | +import type { PlatformError } from "@effect/platform/Error" |
| 2 | +import type * as FileSystem from "@effect/platform/FileSystem" |
| 3 | +import type * as Path from "@effect/platform/Path" |
| 4 | +import { |
| 5 | + claudeOauthTokenFileMode, |
| 6 | + claudeOauthTokenPath, |
| 7 | + formatClaudeOauthTokenFile |
| 8 | +} from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token" |
| 9 | +import { Effect } from "effect" |
| 10 | + |
| 11 | +import { isRegularFile } from "./auth-helpers.js" |
| 12 | +import { readFileStringIfPresent, writeFileStringEnsuringParent } from "./volatile-files.js" |
| 13 | + |
| 14 | +type ClaudeAuthMethod = "none" | "oauth-token" | "claude-ai-session" |
| 15 | + |
| 16 | +const claudeConfigFileName = ".claude.json" |
| 17 | +const claudeCredentialsFileName = ".credentials.json" |
| 18 | +const claudeCredentialsDirName = ".claude" |
| 19 | + |
| 20 | +export const claudeConfigPath = (accountPath: string): string => `${accountPath}/${claudeConfigFileName}` |
| 21 | +export const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/${claudeCredentialsFileName}` |
| 22 | +export const claudeNestedCredentialsPath = (accountPath: string): string => |
| 23 | + `${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}` |
| 24 | + |
| 25 | +// CHANGE: persist Claude OAuth tokens through a restricted temporary file and atomic rename |
| 26 | +// WHY: the final token path must never receive secret bytes before 0600 permissions are established |
| 27 | +// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder." |
| 28 | +// REF: issue-439/pr-440 |
| 29 | +// SOURCE: n/a |
| 30 | +// FORMAT THEOREM: forall token, path: write(secret, final(path)) only by rename(temp0600, final(path)) |
| 31 | +// PURITY: SHELL |
| 32 | +// EFFECT: Effect<void, PlatformError> |
| 33 | +// INVARIANT: final .oauth-token is regular replacement content with mode 0600 after success |
| 34 | +// COMPLEXITY: O(|token|) |
| 35 | +export const persistClaudeOauthToken = ( |
| 36 | + fs: FileSystem.FileSystem, |
| 37 | + path: Path.Path, |
| 38 | + accountPath: string, |
| 39 | + token: string |
| 40 | +): Effect.Effect<void, PlatformError> => |
| 41 | + Effect.gen(function*(_) { |
| 42 | + const tokenPath = claudeOauthTokenPath(accountPath) |
| 43 | + const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" })) |
| 44 | + const tempPath = path.join(tempDir, ".oauth-token") |
| 45 | + const cleanupTempDir = fs.remove(tempDir, { recursive: true, force: true }).pipe( |
| 46 | + Effect.orElseSucceed(() => void 0) |
| 47 | + ) |
| 48 | + yield* _( |
| 49 | + Effect.gen(function*(_) { |
| 50 | + yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode })) |
| 51 | + yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode)) |
| 52 | + yield* _(fs.rename(tempPath, tokenPath)) |
| 53 | + yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode)) |
| 54 | + }).pipe(Effect.ensuring(cleanupTempDir)) |
| 55 | + ) |
| 56 | + }) |
| 57 | + |
| 58 | +const syncClaudeCredentialsFile = ( |
| 59 | + fs: FileSystem.FileSystem, |
| 60 | + path: Path.Path, |
| 61 | + accountPath: string |
| 62 | +): Effect.Effect<void, PlatformError> => |
| 63 | + Effect.gen(function*(_) { |
| 64 | + const nestedPath = claudeNestedCredentialsPath(accountPath) |
| 65 | + const rootPath = claudeCredentialsPath(accountPath) |
| 66 | + const isNestedExists = yield* _(isRegularFile(fs, nestedPath)) |
| 67 | + if (isNestedExists) { |
| 68 | + const nestedText = yield* _(readFileStringIfPresent(fs, nestedPath)) |
| 69 | + if (nestedText !== null) { |
| 70 | + yield* _(writeFileStringEnsuringParent(fs, path, rootPath, nestedText)) |
| 71 | + yield* _(fs.chmod(rootPath, 0o600), Effect.orElseSucceed(() => void 0)) |
| 72 | + } |
| 73 | + return |
| 74 | + } |
| 75 | + |
| 76 | + const isRootExists = yield* _(isRegularFile(fs, rootPath)) |
| 77 | + if (isRootExists) { |
| 78 | + const rootText = yield* _(readFileStringIfPresent(fs, rootPath)) |
| 79 | + if (rootText === null) { |
| 80 | + return |
| 81 | + } |
| 82 | + yield* _(writeFileStringEnsuringParent(fs, path, nestedPath, rootText)) |
| 83 | + yield* _(fs.chmod(nestedPath, 0o600), Effect.orElseSucceed(() => void 0)) |
| 84 | + } |
| 85 | + }) |
| 86 | + |
| 87 | +const clearClaudeSessionCredentials = ( |
| 88 | + fs: FileSystem.FileSystem, |
| 89 | + accountPath: string |
| 90 | +): Effect.Effect<void, PlatformError> => |
| 91 | + Effect.gen(function*(_) { |
| 92 | + yield* _(fs.remove(claudeCredentialsPath(accountPath), { force: true })) |
| 93 | + yield* _(fs.remove(claudeNestedCredentialsPath(accountPath), { force: true })) |
| 94 | + }) |
| 95 | + |
| 96 | +const hasNonEmptyOauthToken = ( |
| 97 | + fs: FileSystem.FileSystem, |
| 98 | + accountPath: string |
| 99 | +): Effect.Effect<boolean, PlatformError> => |
| 100 | + Effect.gen(function*(_) { |
| 101 | + const tokenPath = claudeOauthTokenPath(accountPath) |
| 102 | + const hasToken = yield* _(isRegularFile(fs, tokenPath)) |
| 103 | + if (!hasToken) { |
| 104 | + return false |
| 105 | + } |
| 106 | + const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) |
| 107 | + return tokenText.trim().length > 0 |
| 108 | + }) |
| 109 | + |
| 110 | +export const readOauthToken = ( |
| 111 | + fs: FileSystem.FileSystem, |
| 112 | + accountPath: string |
| 113 | +): Effect.Effect<string | null, PlatformError> => |
| 114 | + Effect.gen(function*(_) { |
| 115 | + const tokenPath = claudeOauthTokenPath(accountPath) |
| 116 | + const hasToken = yield* _(isRegularFile(fs, tokenPath)) |
| 117 | + if (!hasToken) { |
| 118 | + return null |
| 119 | + } |
| 120 | + |
| 121 | + const tokenText = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) |
| 122 | + const token = tokenText.trim() |
| 123 | + return token.length > 0 ? token : null |
| 124 | + }) |
| 125 | + |
| 126 | +export const resolveClaudeAuthMethod = ( |
| 127 | + fs: FileSystem.FileSystem, |
| 128 | + path: Path.Path, |
| 129 | + accountPath: string |
| 130 | +): Effect.Effect<ClaudeAuthMethod, PlatformError> => |
| 131 | + Effect.gen(function*(_) { |
| 132 | + const hasOauthToken = yield* _(hasNonEmptyOauthToken(fs, accountPath)) |
| 133 | + if (hasOauthToken) { |
| 134 | + yield* _(clearClaudeSessionCredentials(fs, accountPath)) |
| 135 | + return "oauth-token" |
| 136 | + } |
| 137 | + |
| 138 | + yield* _(syncClaudeCredentialsFile(fs, path, accountPath)) |
| 139 | + const hasCredentials = yield* _(isRegularFile(fs, claudeCredentialsPath(accountPath))) |
| 140 | + return hasCredentials ? "claude-ai-session" : "none" |
| 141 | + }) |
0 commit comments