Skip to content

Commit ae12482

Browse files
committed
fix(auth): show Claude account in status
Expose a nullable Claude account field in the controller status response and render it in the status message when safe identity metadata is available. Proof of fix: - Cause: Claude status reported only label/method, so a connected account could not be identified like Codex status. - Solution: read non-secret account metadata from persisted Claude config/credentials, keep token bytes out of the response, and update OpenAPI/contracts/tests. - Verification: bun run --cwd packages/api test -- auth.test.ts openapi.test.ts; bun run --cwd packages/app test -- program-auth.test.ts parser-auth.test.ts; bun run --cwd packages/lib lint.
1 parent 0d8a463 commit ae12482

13 files changed

Lines changed: 448 additions & 275 deletions

File tree

packages/api/src/api/contracts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ export type ClaudeAuthStatus = {
304304
readonly message: string
305305
readonly connected: boolean
306306
readonly authPath: string
307+
readonly account: string | null
307308
readonly method: "none" | "oauth-token" | "claude-ai-session"
308309
}
309310

packages/api/src/api/openapi.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export const CodexStatusResponseSchema = Schema.Struct({
252252
})
253253

254254
export const ClaudeAuthStatusSchema = Schema.Struct({
255+
account: NullableStringSchema,
255256
authPath: Schema.String,
256257
connected: Schema.Boolean,
257258
label: Schema.String,

packages/api/src/services/auth.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,14 +523,18 @@ const grokAuthStatus = (
523523
const claudeAuthStatus = (
524524
label: string,
525525
authPath: string,
526-
method: ClaudeAuthMethod
526+
method: ClaudeAuthMethod,
527+
account: string | null
527528
): ClaudeAuthStatus => ({
528529
label,
529530
message: method === "none"
530531
? `Claude not connected (${label}).`
531-
: `Claude connected (${label}, ${method}).`,
532+
: account === null
533+
? `Claude connected (${label}, ${method}, account unavailable).`
534+
: `Claude connected (${label}, ${method}, account: ${account}).`,
532535
connected: method !== "none",
533536
authPath,
537+
account,
534538
method
535539
})
536540

@@ -578,6 +582,119 @@ const resolveClaudeAuthMethod = (
578582
return hasNestedCredentials ? "claude-ai-session" : "none"
579583
})
580584

585+
const readJsonRecordFile = (
586+
fs: FileSystem.FileSystem,
587+
filePath: string
588+
): Effect.Effect<JsonRecord | null, PlatformError> =>
589+
fs.readFileString(filePath).pipe(
590+
Effect.flatMap((text) =>
591+
Effect.try({
592+
try: (): unknown => JSON.parse(text),
593+
catch: () => null
594+
}).pipe(
595+
Effect.map((parsed) => isRecord(parsed) ? parsed : null),
596+
Effect.catchAll(() => Effect.succeed(null))
597+
)
598+
),
599+
Effect.orElseSucceed(() => null)
600+
)
601+
602+
const readFirstString = (
603+
record: JsonRecord,
604+
keys: ReadonlyArray<string>
605+
): string | null => {
606+
for (const key of keys) {
607+
const value = readString(record, key)
608+
if (value !== null) {
609+
return value
610+
}
611+
}
612+
return null
613+
}
614+
615+
const accountIdentityKeys: ReadonlyArray<string> = [
616+
"emailAddress",
617+
"email",
618+
"preferred_username",
619+
"displayName",
620+
"name",
621+
"accountUuid",
622+
"account_id",
623+
"accountId",
624+
"userID",
625+
"userId",
626+
"organizationUuid"
627+
]
628+
629+
const accountContainerKeys: ReadonlyArray<string> = [
630+
"oauthAccount",
631+
"claudeAiOauth",
632+
"account",
633+
"user",
634+
"profile"
635+
]
636+
637+
const jwtTokenKeys: ReadonlyArray<string> = [
638+
"idToken",
639+
"id_token",
640+
"accessToken",
641+
"access_token"
642+
]
643+
644+
const extractClaudeAccountFromRecord = (record: JsonRecord): string | null => {
645+
const direct = readFirstString(record, accountIdentityKeys)
646+
if (direct !== null) {
647+
return direct
648+
}
649+
650+
const token = readFirstString(record, jwtTokenKeys)
651+
const claims = decodeJwtClaims(token)
652+
if (claims !== null) {
653+
const claimAccount = readFirstString(claims, accountIdentityKeys)
654+
if (claimAccount !== null) {
655+
return claimAccount
656+
}
657+
}
658+
659+
for (const key of accountContainerKeys) {
660+
const nested = record[key]
661+
if (isRecord(nested)) {
662+
const account = extractClaudeAccountFromRecord(nested)
663+
if (account !== null) {
664+
return account
665+
}
666+
}
667+
}
668+
669+
return null
670+
}
671+
672+
const readClaudeAuthAccount = (
673+
fs: FileSystem.FileSystem,
674+
path: Path.Path,
675+
accountPath: string
676+
): Effect.Effect<string | null, PlatformError> =>
677+
Effect.gen(function*(_) {
678+
const candidates: ReadonlyArray<string> = [
679+
path.join(accountPath, ".claude.json"),
680+
path.join(accountPath, ".credentials.json"),
681+
path.join(accountPath, ".claude", ".credentials.json")
682+
]
683+
684+
for (const candidate of candidates) {
685+
const record = yield* _(readJsonRecordFile(fs, candidate))
686+
if (record === null) {
687+
continue
688+
}
689+
const account = extractClaudeAccountFromRecord(record)
690+
if (account !== null) {
691+
return account
692+
}
693+
}
694+
695+
return null
696+
})
697+
581698
// CHANGE: expose Claude auth status through the controller without running the Claude CLI
582699
// WHY: host API mode must route `docker-git auth claude status`; status should inspect controller state like Grok/Codex
583700
// QUOTE(ТЗ): "Можешь сделать что бы работал claude status?"
@@ -596,7 +713,8 @@ export const readClaudeAuthStatus = (
596713
const path = yield* _(Path.Path)
597714
const { accountLabel, accountPath } = resolveClaudeAccountPath(path, label)
598715
const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath))
599-
return claudeAuthStatus(accountLabel, accountPath, method)
716+
const account = method === "none" ? null : yield* _(readClaudeAuthAccount(fs, path, accountPath))
717+
return claudeAuthStatus(accountLabel, accountPath, method, account)
600718
})
601719

602720
export const readGrokAuthStatus = (

packages/api/tests/auth.test.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,12 @@ describe("api auth", () => {
525525

526526
yield* _(fs.makeDirectory(accountDir, { recursive: true }))
527527
yield* _(fs.writeFileString(path.join(accountDir, ".oauth-token"), "sk-ant-oat01-test\n"))
528+
yield* _(
529+
fs.writeFileString(
530+
path.join(accountDir, ".claude.json"),
531+
JSON.stringify({ oauthAccount: { emailAddress: "team@example.com", accountUuid: "acc-1" } }, null, 2)
532+
)
533+
)
528534

529535
const status = yield* _(
530536
withProjectsRoot(
@@ -535,9 +541,10 @@ describe("api auth", () => {
535541

536542
expect(status.connected).toBe(true)
537543
expect(status.label).toBe("team-a")
544+
expect(status.account).toBe("team@example.com")
538545
expect(status.method).toBe("oauth-token")
539546
expect(status.authPath).toBe(accountDir)
540-
expect(status.message).toBe("Claude connected (team-a, oauth-token).")
547+
expect(status.message).toBe("Claude connected (team-a, oauth-token, account: team@example.com).")
541548
expect(JSON.stringify(status)).not.toContain("sk-ant-oat01-test")
542549
})
543550
).pipe(Effect.provide(NodeContext.layer)))
@@ -551,7 +558,12 @@ describe("api auth", () => {
551558
const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a")
552559

553560
yield* _(fs.makeDirectory(accountDir, { recursive: true }))
554-
yield* _(fs.writeFileString(path.join(accountDir, ".credentials.json"), "{\"session\":\"ok\"}\n"))
561+
yield* _(
562+
fs.writeFileString(
563+
path.join(accountDir, ".credentials.json"),
564+
JSON.stringify({ claudeAiOauth: { displayName: "Team Claude" } }, null, 2)
565+
)
566+
)
555567

556568
const status = yield* _(
557569
withProjectsRoot(
@@ -562,9 +574,10 @@ describe("api auth", () => {
562574

563575
expect(status.connected).toBe(true)
564576
expect(status.label).toBe("team-a")
577+
expect(status.account).toBe("Team Claude")
565578
expect(status.method).toBe("claude-ai-session")
566579
expect(status.authPath).toBe(accountDir)
567-
expect(status.message).toBe("Claude connected (team-a, claude-ai-session).")
580+
expect(status.message).toBe("Claude connected (team-a, claude-ai-session, account: Team Claude).")
568581
})
569582
).pipe(Effect.provide(NodeContext.layer)))
570583

@@ -589,9 +602,10 @@ describe("api auth", () => {
589602

590603
expect(status.connected).toBe(true)
591604
expect(status.label).toBe("team-a")
605+
expect(status.account).toBeNull()
592606
expect(status.method).toBe("claude-ai-session")
593607
expect(status.authPath).toBe(accountDir)
594-
expect(status.message).toBe("Claude connected (team-a, claude-ai-session).")
608+
expect(status.message).toBe("Claude connected (team-a, claude-ai-session, account unavailable).")
595609
})
596610
).pipe(Effect.provide(NodeContext.layer)))
597611

@@ -611,6 +625,7 @@ describe("api auth", () => {
611625

612626
expect(status.connected).toBe(false)
613627
expect(status.label).toBe("default")
628+
expect(status.account).toBeNull()
614629
expect(status.method).toBe("none")
615630
expect(status.authPath).toBe(accountDir)
616631
expect(status.message).toBe("Claude not connected (default).")

packages/auth-oauth/src/claude-docker-oauth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818

1919
export const defaultClaudeDockerOauthImage = "docker-git-auth-claude:latest"
2020
export const defaultClaudeDockerOauthContainerHome = "/claude-home"
21-
const claudeDockerOauthProbeConfigDir = "/tmp/docker-git-claude-probe"
21+
const claudeDockerOauthProbeConfigDir = "/claude-probe-home"
2222
export const claudeDockerOauthBaseImage =
2323
"node:24-bookworm-slim@sha256:b31e7a42fdf8b8aa5f5ed477c72d694301273f1069c5a2f71d53c6482e99a2fc"
2424
export const claudeDockerOauthClaudeCodeVersion = "2.1.195"

packages/auth-oauth/tests/claude-docker-oauth.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ describe("Claude Docker OAuth runner", () => {
102102
expect(probeRuns[0]?.args.slice(-3)).toEqual(["claude-test:latest", "-p", "ping"])
103103
expect(dockerEnvEntries(probeRuns[0]?.args ?? [])).toEqual(
104104
expect.arrayContaining([
105-
"CLAUDE_CONFIG_DIR=/tmp/docker-git-claude-probe",
106-
"HOME=/tmp/docker-git-claude-probe",
105+
"CLAUDE_CONFIG_DIR=/claude-probe-home",
106+
"HOME=/claude-probe-home",
107107
`CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`
108108
])
109109
)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)