Skip to content

Commit 5ba9e51

Browse files
committed
fix(auth): persist claude oauth token atomically
1 parent bbd1885 commit 5ba9e51

2 files changed

Lines changed: 76 additions & 3 deletions

File tree

packages/lib/src/usecases/auth-claude.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,36 @@ const claudeCredentialsPath = (accountPath: string): string => `${accountPath}/$
5050
const claudeNestedCredentialsPath = (accountPath: string): string =>
5151
`${accountPath}/${claudeCredentialsDirName}/${claudeCredentialsFileName}`
5252

53+
// CHANGE: persist Claude OAuth tokens through a restricted temporary file and atomic rename
54+
// WHY: the final token path must never receive secret bytes before 0600 permissions are established
55+
// QUOTE(ТЗ): "Исправь CI/CD и все правки от Rabbit Coder."
56+
// REF: issue-439/pr-440
57+
// SOURCE: n/a
58+
// FORMAT THEOREM: forall token, path: write(secret, final(path)) only by rename(temp0600, final(path))
59+
// PURITY: SHELL
60+
// EFFECT: Effect<void, PlatformError>
61+
// INVARIANT: final .oauth-token is regular replacement content with mode 0600 after success
62+
// COMPLEXITY: O(|token|)
5363
const persistClaudeOauthToken = (
5464
fs: FileSystem.FileSystem,
65+
path: Path.Path,
5566
accountPath: string,
5667
token: string
5768
): Effect.Effect<void, PlatformError> =>
5869
Effect.gen(function*(_) {
5970
const tokenPath = claudeOauthTokenPath(accountPath)
60-
yield* _(fs.writeFileString(tokenPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode }))
61-
yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode))
71+
const tempDir = yield* _(fs.makeTempDirectory({ directory: accountPath, prefix: ".oauth-token-write-" }))
72+
const tempPath = path.join(tempDir, ".oauth-token")
73+
yield* _(
74+
Effect.gen(function*(_) {
75+
yield* _(fs.writeFileString(tempPath, formatClaudeOauthTokenFile(token), { mode: claudeOauthTokenFileMode }))
76+
yield* _(fs.chmod(tempPath, claudeOauthTokenFileMode))
77+
yield* _(fs.rename(tempPath, tokenPath))
78+
yield* _(fs.chmod(tokenPath, claudeOauthTokenFileMode))
79+
}).pipe(
80+
Effect.ensuring(fs.remove(tempDir, { recursive: true, force: true }).pipe(Effect.orElseSucceed(() => void 0)))
81+
)
82+
)
6283
})
6384

6485
const syncClaudeCredentialsFile = (
@@ -264,7 +285,7 @@ export const authClaudeLogin = (
264285
image: claudeImageName,
265286
containerPath: claudeContainerHomeDir
266287
}),
267-
persistToken: (token) => persistClaudeOauthToken(fs, accountPath, token),
288+
persistToken: (token) => persistClaudeOauthToken(fs, path, accountPath, token),
268289
normalizeStoredCredentials: resolveClaudeAuthMethod(fs, path, accountPath).pipe(Effect.asVoid),
269290
probeToken: (token) => runClaudePingProbeExitCode(cwd, accountPath, token),
270291
syncState: autoSyncState(`chore(state): auth claude ${accountLabel}`)

packages/lib/tests/usecases/auth-claude-login.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as CommandExecutor from "@effect/platform/CommandExecutor"
33
import * as FileSystem from "@effect/platform/FileSystem"
44
import * as Path from "@effect/platform/Path"
55
import { NodeContext } from "@effect/platform-node"
6+
import { claudeOauthTokenFileMode } from "@prover-coder-ai/docker-git-auth-oauth/claude-oauth-token"
67
import { describe, expect, it } from "@effect/vitest"
78
import { Effect, Logger } from "effect"
89
import * as Inspectable from "effect/Inspectable"
@@ -211,6 +212,57 @@ describe("authClaudeLogin", () => {
211212
)
212213
).pipe(Effect.provide(NodeContext.layer)))
213214

215+
it.effect("replaces an existing token symlink without writing the secret to the symlink target", () =>
216+
withTempDir((root) =>
217+
withPatchedEnv(
218+
{ HOME: root, DOCKER_GIT_STATE_AUTO_SYNC: "0", DOCKER_GIT_PROJECTS_ROOT: undefined },
219+
Effect.gen(function*(_) {
220+
const fs = yield* _(FileSystem.FileSystem)
221+
const path = yield* _(Path.Path)
222+
const claudeAuthPath = path.join(root, ".docker-git/.orch/auth/claude")
223+
const accountPath = path.join(claudeAuthPath, "default")
224+
const tokenPath = path.join(accountPath, ".oauth-token")
225+
const outsidePath = path.join(root, "outside-token-target")
226+
yield* _(fs.makeDirectory(accountPath, { recursive: true }))
227+
yield* _(fs.writeFileString(outsidePath, "outside-sentinel\n"))
228+
yield* _(fs.symlink(outsidePath, tokenPath))
229+
let finalTokenWrites = 0
230+
const guardedFs: FileSystem.FileSystem = {
231+
...fs,
232+
writeFileString: (targetPath, data, options) =>
233+
(targetPath === tokenPath
234+
? Effect.sync(() => {
235+
finalTokenWrites += 1
236+
})
237+
: Effect.void).pipe(
238+
Effect.zipRight(fs.writeFileString(targetPath, data, options))
239+
)
240+
}
241+
242+
yield* _(
243+
authClaudeLogin({
244+
_tag: "AuthClaudeLogin",
245+
label: null,
246+
claudeAuthPath
247+
}).pipe(
248+
Effect.provideService(FileSystem.FileSystem, guardedFs),
249+
Effect.provideService(CommandExecutor.CommandExecutor, makeFakeExecutor(oauthToken, 0))
250+
)
251+
)
252+
253+
const outsideText = yield* _(fs.readFileString(outsidePath))
254+
const tokenText = yield* _(fs.readFileString(tokenPath))
255+
const tokenInfo = yield* _(fs.stat(tokenPath))
256+
257+
expect(outsideText).toBe("outside-sentinel\n")
258+
expect(tokenText.trim()).toBe(oauthToken)
259+
expect(tokenInfo.type).toBe("File")
260+
expect(Number(tokenInfo.mode) & 0o777).toBe(claudeOauthTokenFileMode)
261+
expect(finalTokenWrites).toBe(0)
262+
})
263+
)
264+
).pipe(Effect.provide(NodeContext.layer)))
265+
214266
it.effect("fails when setup-token completes without a captured OAuth token", () =>
215267
withTempDir((root) =>
216268
withPatchedEnv(

0 commit comments

Comments
 (0)