diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 3ada12297649d..61d7365114c69 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -132,10 +132,22 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + ClaudeResetCredits.readClaudeResetCredits( + credentialsDir, + keychainConfigDir, + version, + ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), @@ -321,10 +338,11 @@ export const ClaudeDriver: ProviderDriver ClaudeResetCredits.consumeClaudeResetCredit({ - configDir, + configDir: credentialsDir, + customConfigDir: keychainConfigDir, accountConfigPath, version, grantId, @@ -334,6 +352,7 @@ export const ClaudeDriver: ProviderDriver ); const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); +// Answers `security find-generic-password` like the Keychain would, recording each service looked up. +const keychain = (entries: Record, lookups: Array) => + ChildProcessSpawner.make((command) => { + const args = ChildProcess.isStandardCommand(command) ? command.args.map(String) : []; + const service = args[args.indexOf("-s") + 1] ?? ""; + lookups.push(service); + const secret = entries[service]; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(secret === undefined ? 44 : 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(secret ?? "")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); +const bearer = (token: string) => + HttpClient.make((request) => { + expect(request.headers.authorization).toBe(`Bearer ${token}`); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + describe("claudeResetCreditsToContract", () => { it("counts live grants and pins the next usable one", () => { expect( @@ -149,22 +188,82 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { ), ); }); - const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const lookups: Array = []; + const credits = yield* ClaudeResetCredits.readClaudeResetCredits( + configDir, + undefined, + "2.1.0", + ).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, client), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, keychain({}, lookups)), ); expect(credits).toMatchObject({ availableCount: 1, nextCreditId: "grant_a" }); + expect(lookups).toEqual([]); }), ); - it.effect("reads nothing from keychain logins or failed requests", () => + it.effect("reads a macOS login from the entry Claude Code keeps for its config folder", () => Effect.gen(function* () { const { configDir } = yield* writeLogin; - const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const login = (token: string) => JSON.stringify({ claudeAiOauth: { accessToken: token } }); + const custom = ClaudeResetCredits.claudeKeychainService("/Users/me/.claude-work"); + // The suffix Claude Code 2.1 derives: sha256 of the folder, first 8 hex characters. + expect(custom).toBe("Claude Code-credentials-1e91dd84"); + const entries = { + "Claude Code-credentials": login("default-token"), + [custom]: login("work-token"), + }; + const lookups: Array = []; + const read = (customConfigDir: string | undefined, token: string) => + ClaudeResetCredits.readClaudeResetCredits(configDir, customConfigDir, "2.1.0").pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService(HttpClient.HttpClient, bearer(token)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + keychain(entries, lookups), + ), + ); + expect(yield* read(undefined, "default-token")).toMatchObject({ + availableCount: 1, + nextCreditId: "grant_a", + }); + expect(yield* read("/Users/me/.claude-work", "work-token")).toMatchObject({ + availableCount: 1, + nextCreditId: "grant_a", + }); + // No Keychain entry: Claude Code's plaintext fallback file. + expect(yield* read("/Users/me/.claude-other", "oauth-token")).toMatchObject({ + availableCount: 1, + nextCreditId: "grant_a", + }); + expect(lookups).toEqual([ + "Claude Code-credentials", + custom, + ClaudeResetCredits.claudeKeychainService("/Users/me/.claude-other"), + ]); + }), + ); + + it.effect("reads nothing without a login or from failed requests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const signedOut = yield* fs.makeTempDirectoryScoped(); + const darwin = yield* ClaudeResetCredits.readClaudeResetCredits( + signedOut, + undefined, + "2.1.0", + ).pipe( Effect.provideService(HostProcess.Platform, "darwin"), Effect.provideService(HttpClient.HttpClient, refuseRequests), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, keychain({}, [])), ); - const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + const { configDir } = yield* writeLogin; + const limited = yield* ClaudeResetCredits.readClaudeResetCredits( + configDir, + undefined, + "2.1.0", + ).pipe( Effect.provideService(HostProcess.Platform, "linux"), Effect.provideService(HttpClient.HttpClient, respond(429, {})), ); @@ -183,6 +282,7 @@ const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requ const login = yield* writeLogin; return yield* ClaudeResetCredits.consumeClaudeResetCredit({ ...login, + customConfigDir: undefined, version: "2.1.0", ...ids, }).pipe( diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index e1dbacc34ba71..a45c28778ae7a 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the Keychain name is a plain function, as in Claude Code. +import * as NodeCrypto from "node:crypto"; import type { ProviderConsumeResetCreditOutcome, ServerProviderResetCreditOption, @@ -10,6 +12,8 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; const API_BASE = "https://api.anthropic.com"; @@ -46,6 +50,7 @@ const UsageResponse = Schema.Struct({ cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const decodeCredentialsJson = Schema.decodeEffect(Schema.fromJsonString(Credentials)); const ClaimResponse = Schema.Struct({ result: Schema.Literals([ "reset", @@ -188,10 +193,45 @@ const readJson = Effect.fn("claudeResetCredits.readJson")(function* + customConfigDir + ? `Claude Code-credentials-${NodeCrypto.createHash("sha256").update(customConfigDir.normalize("NFC")).digest("hex").slice(0, 8)}` + : "Claude Code-credentials"; + +// /usr/bin/security wrote the entry for Claude Code, so it reads it back without a prompt. +const readKeychainLogin = Effect.fn("claudeResetCredits.readKeychainLogin")( + function* (customConfigDir: string | undefined) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + "/usr/bin/security", + ["find-generic-password", "-s", claudeKeychainService(customConfigDir), "-w"], + { stdin: "ignore", stderr: "ignore" }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [child.stdout.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], + { concurrency: 2 }, + ); + if (Number(exitCode) !== 0) return undefined; + const credentials = yield* decodeCredentialsJson(stdout); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; + }, + Effect.scoped, + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => undefined), +); + +/** customConfigDir is the dir Claude keys its Keychain entry on, if any. */ const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function* ( configDir: string, + customConfigDir: string | undefined, ) { - if ((yield* HostProcess.Platform) === "darwin") return undefined; + if ((yield* HostProcess.Platform) === "darwin") { + const token = yield* readKeychainLogin(customConfigDir); + if (token) return token; + } const path = yield* Path.Path; const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); return credentials.claudeAiOauth?.accessToken?.trim() || undefined; @@ -205,8 +245,8 @@ const withClaudeHeaders = (token: string, version: string) => }); export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( - function* (configDir: string, version: string) { - const token = yield* readAccessToken(configDir); + function* (configDir: string, customConfigDir: string | undefined, version: string) { + const token = yield* readAccessToken(configDir, customConfigDir); if (!token) return undefined; const client = yield* HttpClient.HttpClient; const response = yield* client.execute( @@ -251,6 +291,7 @@ const CLAIM_OUTCOMES = { /** requestId is the claim's idempotency key. */ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { readonly configDir: string; + readonly customConfigDir: string | undefined; readonly accountConfigPath: string; readonly version: string; readonly grantId: string; @@ -259,7 +300,7 @@ export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(fu if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); } - const token = yield* readAccessToken(input.configDir).pipe( + const token = yield* readAccessToken(input.configDir, input.customConfigDir).pipe( Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), ); const config = yield* readJson(Config, input.accountConfigPath).pipe( diff --git a/docs/user/usage.md b/docs/user/usage.md index e6b3a46d5a14e..3207b4b893046 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -121,8 +121,7 @@ when the next reset lands and how much it hands back. The hatched part of a segment is what that reset restores. Tap a segment or account row for the account's plan, where it is signed in, and its reset time. On web, you can hover too. Codex and Claude accounts with banked reset credits show a ticket count and the **Use reset** action in the account details. -Claude resets are not available when the server runs on macOS, where Claude keeps its login in the -Keychain. On narrow screens, numbered rows below +On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once.