Skip to content

Commit 0d8a463

Browse files
committed
fix(auth): route Claude status through controller
Proof of fix:\n- Причина: AuthClaudeStatus парсился, но host API mode считал его unsupported и не имел /auth/claude/status endpoint.\n- Решение: добавлен controller/API status endpoint, app routing/client dispatch и OpenAPI контракт; статус читает controller state без запуска Claude CLI и без утечки секретов.\n- Доказательство: 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; typecheck packages api/app/openapi.
1 parent c114e64 commit 0d8a463

14 files changed

Lines changed: 692 additions & 6 deletions

File tree

packages/api/src/api/contracts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,14 @@ export type CodexAuthStatus = {
299299
readonly account: string | null
300300
}
301301

302+
export type ClaudeAuthStatus = {
303+
readonly label: string
304+
readonly message: string
305+
readonly connected: boolean
306+
readonly authPath: string
307+
readonly method: "none" | "oauth-token" | "claude-ai-session"
308+
}
309+
302310
export type GrokAuthStatus = {
303311
readonly label: string
304312
readonly message: string

packages/api/src/api/openapi.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,19 @@ export const CodexStatusResponseSchema = Schema.Struct({
251251
status: CodexAuthStatusSchema
252252
})
253253

254+
export const ClaudeAuthStatusSchema = Schema.Struct({
255+
authPath: Schema.String,
256+
connected: Schema.Boolean,
257+
label: Schema.String,
258+
message: Schema.String,
259+
method: Schema.Literal("none", "oauth-token", "claude-ai-session")
260+
})
261+
262+
export const ClaudeStatusResponseSchema = Schema.Struct({
263+
ok: OptionalOkSchema,
264+
status: ClaudeAuthStatusSchema
265+
})
266+
254267
export const GrokAuthStatusSchema = Schema.Struct({
255268
authPath: Schema.String,
256269
connected: Schema.Boolean,
@@ -601,6 +614,11 @@ const AuthGroup = HttpApiGroup.make("auth")
601614
.setUrlParams(QueryLabelSchema)
602615
.addSuccess(CodexStatusResponseSchema)
603616
)
617+
.add(
618+
endpoint.get("claudeStatus", "/auth/claude/status")
619+
.setUrlParams(QueryLabelSchema)
620+
.addSuccess(ClaudeStatusResponseSchema)
621+
)
604622
.add(
605623
endpoint.post("githubLogin", "/auth/github/login")
606624
.setPayload(GithubAuthLoginRequestSchema)

packages/api/src/http.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
logoutGitAuth,
6060
logoutGitlabAuth,
6161
logoutGithubAuth,
62+
readClaudeAuthStatus,
6263
readCodexAuthStatus,
6364
readGrokAuthStatus,
6465
readGitAuthStatus,
@@ -1138,6 +1139,15 @@ export const makeRouter = () => {
11381139
return yield* _(jsonResponse({ status }, 200))
11391140
}).pipe(Effect.catchAll(errorResponse))
11401141
),
1142+
HttpRouter.get(
1143+
"/auth/claude/status",
1144+
Effect.gen(function*(_) {
1145+
const request = yield* _(HttpServerRequest.HttpServerRequest)
1146+
const label = new URL(request.url, "http://localhost").searchParams.get("label")
1147+
const status = yield* _(readClaudeAuthStatus(label))
1148+
return yield* _(jsonResponse({ status }, 200))
1149+
}).pipe(Effect.catchAll(errorResponse))
1150+
),
11411151
HttpRouter.get(
11421152
"/auth/menu",
11431153
Effect.gen(function*(_) {

packages/api/src/services/auth.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type {
3737
CodexAuthLoginRequest,
3838
CodexAuthLogoutRequest,
3939
CodexAuthStatus,
40+
ClaudeAuthStatus,
4041
GrokAuthLogoutRequest,
4142
GrokAuthStatus,
4243
GitAuthLoginRequest,
@@ -64,6 +65,7 @@ export const gitlabAuthRequiredMessage = [
6465
"If the repository requires access, run: docker-git auth gitlab login"
6566
].join("\n")
6667
export const githubAuthEnvGlobalPath = defaultTemplateConfig.envGlobalPath
68+
export const claudeAuthPath = ".docker-git/.orch/auth/claude"
6769
export const codexAuthPath = defaultTemplateConfig.codexAuthPath
6870
export const grokAuthPath = defaultTemplateConfig.grokAuthPath
6971

@@ -80,6 +82,7 @@ type JsonRecord = Readonly<Record<string, unknown>>
8082
type CodexRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
8183
type CodexCommandError = CommandFailedError | PlatformError
8284
type GrokCommandError = CommandFailedError | PlatformError
85+
type ClaudeAuthMethod = ClaudeAuthStatus["method"]
8386

8487
const labelFromKey = (key: string): string =>
8588
key.startsWith(githubTokenPrefix) ? key.slice(githubTokenPrefix.length) : "default"
@@ -517,6 +520,85 @@ const grokAuthStatus = (
517520
method
518521
})
519522

523+
const claudeAuthStatus = (
524+
label: string,
525+
authPath: string,
526+
method: ClaudeAuthMethod
527+
): ClaudeAuthStatus => ({
528+
label,
529+
message: method === "none"
530+
? `Claude not connected (${label}).`
531+
: `Claude connected (${label}, ${method}).`,
532+
connected: method !== "none",
533+
authPath,
534+
method
535+
})
536+
537+
const resolveClaudeAccountPath = (
538+
path: Path.Path,
539+
label: string | null | undefined
540+
): {
541+
readonly accountLabel: string
542+
readonly accountPath: string
543+
} => {
544+
const rootPath = resolvePathFromCwd(path, process.cwd(), claudeAuthPath)
545+
const accountLabel = normalizeAccountLabel(label ?? null, "default")
546+
return {
547+
accountLabel,
548+
accountPath: path.join(rootPath, accountLabel)
549+
}
550+
}
551+
552+
const readNonEmptyFile = (
553+
fs: FileSystem.FileSystem,
554+
filePath: string
555+
): Effect.Effect<boolean, PlatformError> =>
556+
fs.readFileString(filePath).pipe(
557+
Effect.map((text) => text.trim().length > 0),
558+
Effect.orElseSucceed(() => false)
559+
)
560+
561+
const resolveClaudeAuthMethod = (
562+
fs: FileSystem.FileSystem,
563+
path: Path.Path,
564+
accountPath: string
565+
): Effect.Effect<ClaudeAuthMethod, PlatformError> =>
566+
Effect.gen(function*(_) {
567+
const hasOauthToken = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".oauth-token")))
568+
if (hasOauthToken) {
569+
return "oauth-token"
570+
}
571+
572+
const hasRootCredentials = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".credentials.json")))
573+
if (hasRootCredentials) {
574+
return "claude-ai-session"
575+
}
576+
577+
const hasNestedCredentials = yield* _(readNonEmptyFile(fs, path.join(accountPath, ".claude", ".credentials.json")))
578+
return hasNestedCredentials ? "claude-ai-session" : "none"
579+
})
580+
581+
// CHANGE: expose Claude auth status through the controller without running the Claude CLI
582+
// WHY: host API mode must route `docker-git auth claude status`; status should inspect controller state like Grok/Codex
583+
// QUOTE(ТЗ): "Можешь сделать что бы работал claude status?"
584+
// REF: user-report-2026-07-01-claude-status-api-mode
585+
// SOURCE: n/a
586+
// FORMAT THEOREM: forall label: status(label) = connected iff persisted Claude credential exists
587+
// PURITY: SHELL
588+
// EFFECT: Effect<ClaudeAuthStatus, PlatformError, FileSystem | Path>
589+
// INVARIANT: secrets are never returned; only method/label/path are exposed
590+
// COMPLEXITY: O(1)
591+
export const readClaudeAuthStatus = (
592+
label?: string | null | undefined
593+
): Effect.Effect<ClaudeAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>
594+
Effect.gen(function*(_) {
595+
const fs = yield* _(FileSystem.FileSystem)
596+
const path = yield* _(Path.Path)
597+
const { accountLabel, accountPath } = resolveClaudeAccountPath(path, label)
598+
const method = yield* _(resolveClaudeAuthMethod(fs, path, accountPath))
599+
return claudeAuthStatus(accountLabel, accountPath, method)
600+
})
601+
520602
export const readGrokAuthStatus = (
521603
label?: string | null | undefined
522604
): Effect.Effect<GrokAuthStatus, PlatformError, FileSystem.FileSystem | Path.Path> =>

packages/api/tests/auth.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
logoutCodexAuth,
2020
logoutGitAuth,
2121
logoutGrokAuth,
22+
readClaudeAuthStatus,
2223
readCodexAuthStatus,
2324
readGitAuthStatus,
2425
readGrokAuthStatus,
@@ -514,6 +515,108 @@ describe("api auth", () => {
514515
})
515516
).pipe(Effect.provide(NodeContext.layer)))
516517

518+
it.effect("reads labeled Claude OAuth status from controller state", () =>
519+
withTempDir((root) =>
520+
Effect.gen(function*(_) {
521+
const fs = yield* _(FileSystem.FileSystem)
522+
const path = yield* _(Path.Path)
523+
const projectsRoot = path.join(root, ".docker-git")
524+
const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a")
525+
526+
yield* _(fs.makeDirectory(accountDir, { recursive: true }))
527+
yield* _(fs.writeFileString(path.join(accountDir, ".oauth-token"), "sk-ant-oat01-test\n"))
528+
529+
const status = yield* _(
530+
withProjectsRoot(
531+
projectsRoot,
532+
withWorkingDirectory(root, readClaudeAuthStatus("team-a"))
533+
)
534+
)
535+
536+
expect(status.connected).toBe(true)
537+
expect(status.label).toBe("team-a")
538+
expect(status.method).toBe("oauth-token")
539+
expect(status.authPath).toBe(accountDir)
540+
expect(status.message).toBe("Claude connected (team-a, oauth-token).")
541+
expect(JSON.stringify(status)).not.toContain("sk-ant-oat01-test")
542+
})
543+
).pipe(Effect.provide(NodeContext.layer)))
544+
545+
it.effect("reads labeled Claude root session credentials from controller state", () =>
546+
withTempDir((root) =>
547+
Effect.gen(function*(_) {
548+
const fs = yield* _(FileSystem.FileSystem)
549+
const path = yield* _(Path.Path)
550+
const projectsRoot = path.join(root, ".docker-git")
551+
const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a")
552+
553+
yield* _(fs.makeDirectory(accountDir, { recursive: true }))
554+
yield* _(fs.writeFileString(path.join(accountDir, ".credentials.json"), "{\"session\":\"ok\"}\n"))
555+
556+
const status = yield* _(
557+
withProjectsRoot(
558+
projectsRoot,
559+
withWorkingDirectory(root, readClaudeAuthStatus("team-a"))
560+
)
561+
)
562+
563+
expect(status.connected).toBe(true)
564+
expect(status.label).toBe("team-a")
565+
expect(status.method).toBe("claude-ai-session")
566+
expect(status.authPath).toBe(accountDir)
567+
expect(status.message).toBe("Claude connected (team-a, claude-ai-session).")
568+
})
569+
).pipe(Effect.provide(NodeContext.layer)))
570+
571+
it.effect("reads labeled Claude nested session credentials from controller state", () =>
572+
withTempDir((root) =>
573+
Effect.gen(function*(_) {
574+
const fs = yield* _(FileSystem.FileSystem)
575+
const path = yield* _(Path.Path)
576+
const projectsRoot = path.join(root, ".docker-git")
577+
const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "team-a")
578+
const nestedDir = path.join(accountDir, ".claude")
579+
580+
yield* _(fs.makeDirectory(nestedDir, { recursive: true }))
581+
yield* _(fs.writeFileString(path.join(nestedDir, ".credentials.json"), "{\"session\":\"ok\"}\n"))
582+
583+
const status = yield* _(
584+
withProjectsRoot(
585+
projectsRoot,
586+
withWorkingDirectory(root, readClaudeAuthStatus("team-a"))
587+
)
588+
)
589+
590+
expect(status.connected).toBe(true)
591+
expect(status.label).toBe("team-a")
592+
expect(status.method).toBe("claude-ai-session")
593+
expect(status.authPath).toBe(accountDir)
594+
expect(status.message).toBe("Claude connected (team-a, claude-ai-session).")
595+
})
596+
).pipe(Effect.provide(NodeContext.layer)))
597+
598+
it.effect("reports missing default Claude auth from controller state", () =>
599+
withTempDir((root) =>
600+
Effect.gen(function*(_) {
601+
const path = yield* _(Path.Path)
602+
const projectsRoot = path.join(root, ".docker-git")
603+
const accountDir = path.join(projectsRoot, ".orch", "auth", "claude", "default")
604+
605+
const status = yield* _(
606+
withProjectsRoot(
607+
projectsRoot,
608+
withWorkingDirectory(root, readClaudeAuthStatus(null))
609+
)
610+
)
611+
612+
expect(status.connected).toBe(false)
613+
expect(status.label).toBe("default")
614+
expect(status.method).toBe("none")
615+
expect(status.authPath).toBe(accountDir)
616+
expect(status.message).toBe("Claude not connected (default).")
617+
})
618+
).pipe(Effect.provide(NodeContext.layer)))
619+
517620
it.effect("removes labeled Grok auth from controller state", () =>
518621
withTempDir((root) =>
519622
Effect.gen(function*(_) {

packages/api/tests/openapi.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ describe("openapi contract", () => {
3232
expect(paths["/auth/git/status"]).toBeDefined()
3333
expect(paths["/auth/gitlab/status"]).toBeDefined()
3434
expect(paths["/auth/codex/status"]).toBeDefined()
35+
expect(paths["/auth/claude/status"]).toBeDefined()
3536
expect(paths["/auth/grok/status"]).toBeDefined()
3637
expect(paths["/auth/codex/login"]).toBeUndefined()
3738
expect(paths["/projects/{projectId}/auth/menu"]).toBeDefined()
3839
expect(paths["/projects/{projectId}/auth"]).toBeUndefined()
39-
expect(Object.keys(paths)).toHaveLength(54)
40+
expect(Object.keys(paths)).toHaveLength(55)
4041
}))
4142

4243
it.effect("documents real HTTP success status codes for create and async endpoints", () =>

packages/app/src/docker-git/api-client-auth.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
AuthCodexLoginCommand,
2323
AuthCodexLogoutCommand,
2424
AuthCodexStatusCommand,
25+
AuthClaudeStatusCommand,
2526
AuthGithubLoginCommand,
2627
AuthGithubLogoutCommand,
2728
AuthGithubStatusCommand,
@@ -203,6 +204,11 @@ export const codexStatus = (command: AuthCodexStatusCommand) => {
203204
return request("GET", `/auth/codex/status${query}`)
204205
}
205206

207+
export const claudeStatus = (command: AuthClaudeStatusCommand) => {
208+
const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}`
209+
return request("GET", `/auth/claude/status${query}`)
210+
}
211+
206212
export const grokStatus = (command: AuthGrokStatusCommand) => {
207213
const query = command.label === null ? "" : `?label=${encodeURIComponent(command.label)}`
208214
return request("GET", `/auth/grok/status${query}`)

packages/app/src/docker-git/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828
codexLogin,
2929
codexLogout,
3030
codexStatus,
31+
claudeStatus,
3132
githubLogin,
3233
githubLogout,
3334
githubStatus,

packages/app/src/docker-git/program-auth.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
codexLogin,
77
codexLogout,
88
codexStatus,
9+
claudeStatus,
910
createAuthTerminalSession,
1011
githubLogin,
1112
githubLogout,
@@ -44,6 +45,7 @@ export type RoutedAuthCommand = Extract<
4445
| "AuthGitStatus"
4546
| "AuthGitLogout"
4647
| "AuthClaudeLogin"
48+
| "AuthClaudeStatus"
4749
| "AuthGeminiLogin"
4850
| "AuthGrokLogin"
4951
| "AuthGrokStatus"
@@ -77,6 +79,7 @@ const routedAuthTags: Readonly<Record<string, true>> = {
7779
AuthGithubLogout: true,
7880
AuthGithubStatus: true,
7981
AuthClaudeLogin: true,
82+
AuthClaudeStatus: true,
8083
AuthGeminiLogin: true,
8184
AuthGrokLogin: true,
8285
AuthGrokLogout: true,
@@ -156,6 +159,10 @@ const handleClaudeLoginCommand = (
156159
)
157160
)
158161

162+
const handleClaudeStatusCommand = (
163+
command: Extract<OperationalCommand, { readonly _tag: "AuthClaudeStatus" }>
164+
) => withControllerReady(pipe(claudeStatus(command), Effect.flatMap((payload) => renderAuthPayload(payload))))
165+
159166
const handleGeminiLoginCommand = (
160167
command: Extract<OperationalCommand, { readonly _tag: "AuthGeminiLogin" }>
161168
) =>
@@ -214,6 +221,7 @@ export const dispatchRoutedAuthCommand = (
214221
Match.when({ _tag: "AuthGitStatus" }, handleGitStatusCommand),
215222
Match.when({ _tag: "AuthGitLogout" }, handleGitLogoutCommand),
216223
Match.when({ _tag: "AuthClaudeLogin" }, handleClaudeLoginCommand),
224+
Match.when({ _tag: "AuthClaudeStatus" }, handleClaudeStatusCommand),
217225
Match.when({ _tag: "AuthGeminiLogin" }, handleGeminiLoginCommand),
218226
Match.when({ _tag: "AuthGrokLogin" }, handleGrokLoginCommand),
219227
Match.when({ _tag: "AuthGrokStatus" }, handleGrokStatusCommand),

0 commit comments

Comments
 (0)