From 4095eb0cf4175bf887c5529ba519f5335071f087 Mon Sep 17 00:00:00 2001 From: TinBane <363069+TinBane@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:08:35 +1100 Subject: [PATCH 1/5] fix(server): Claude banked resets work when the server runs on macOS Claude Code keeps its OAuth login in the macOS Keychain, so a Mac server never read Claude's banked resets and never offered Use reset. Read the entry Claude Code uses ("Claude Code-credentials", plus a hash of CLAUDE_CONFIG_DIR when one is set) with /usr/bin/security, falling back to .credentials.json. Fixes #34097 Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/ClaudeDriver.ts | 15 ++- .../src/provider/claudeResetCredits.test.ts | 107 +++++++++++++++++- .../server/src/provider/claudeResetCredits.ts | 49 +++++++- docs/user/usage.md | 3 +- 4 files changed, 160 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2d4b63861074..5beabe40e73ce 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -132,10 +132,14 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + ClaudeResetCredits.readClaudeResetCredits(configDir, customConfigDir, version).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ), @@ -319,6 +324,7 @@ export const ClaudeDriver: ProviderDriver ClaudeResetCredits.consumeClaudeResetCredit({ configDir, + customConfigDir, accountConfigPath, version, grantId, @@ -328,6 +334,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 lookup. +const keychain = (entries: Record, lookups: Array>) => + ChildProcessSpawner.make((command) => { + const args = (command as unknown as { args: ReadonlyArray }).args; + lookups.push(args); + const secret = entries[args[args.indexOf("-s") + 1]!]; + 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( @@ -107,22 +145,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).toEqual({ 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")).toEqual({ + availableCount: 1, + nextCreditId: "grant_a", + }); + expect(yield* read("/Users/me/.claude-work", "work-token")).toEqual({ + availableCount: 1, + nextCreditId: "grant_a", + }); + // No Keychain entry: Claude Code's plaintext fallback file. + expect(yield* read("/Users/me/.claude-other", "oauth-token")).toEqual({ + availableCount: 1, + nextCreditId: "grant_a", + }); + expect(lookups.map((args) => args[args.indexOf("-s") + 1])).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, {})), ); @@ -141,6 +239,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 35973378edada..a5118e56a8c9b 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, ServerProviderResetCredits, @@ -9,6 +11,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"; @@ -42,6 +46,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", @@ -134,10 +139,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 CLAUDE_CONFIG_DIR Claude runs with, 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; @@ -151,8 +191,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( @@ -197,6 +237,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; @@ -205,7 +246,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. From 56ff3a8d882be8652e7cabf7c7cf2d945627b47e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 02:14:35 +0000 Subject: [PATCH 2/5] test(server): keychain reset reads match main's reset-credit shape --- apps/server/src/provider/claudeResetCredits.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 2d52c134f9b75..8b579d2ac71bd 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -223,16 +223,16 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { keychain(entries, lookups), ), ); - expect(yield* read(undefined, "default-token")).toEqual({ + expect(yield* read(undefined, "default-token")).toMatchObject({ availableCount: 1, nextCreditId: "grant_a", }); - expect(yield* read("/Users/me/.claude-work", "work-token")).toEqual({ + 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")).toEqual({ + expect(yield* read("/Users/me/.claude-other", "oauth-token")).toMatchObject({ availableCount: 1, nextCreditId: "grant_a", }); From 50596585edfcc20a103f71bda30cfc828f001bc2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 02:38:00 +0000 Subject: [PATCH 3/5] fix(server): reset-credit login read honors CLAUDE_SECURESTORAGE_CONFIG_DIR --- .../src/provider/Drivers/ClaudeDriver.ts | 18 +++++++++++++++--- apps/server/src/provider/claudeResetCredits.ts | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 3f0e8323716f4..e8b9673ddfbb1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -138,6 +138,14 @@ export const ClaudeDriver: ProviderDriver - ClaudeResetCredits.readClaudeResetCredits(configDir, customConfigDir, version).pipe( + ClaudeResetCredits.readClaudeResetCredits( + credentialsDir, + keychainConfigDir, + version, + ).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), @@ -329,8 +341,8 @@ export const ClaudeDriver: ProviderDriver ClaudeResetCredits.consumeClaudeResetCredit({ - configDir, - customConfigDir, + configDir: credentialsDir, + customConfigDir: keychainConfigDir, accountConfigPath, version, grantId, diff --git a/apps/server/src/provider/claudeResetCredits.ts b/apps/server/src/provider/claudeResetCredits.ts index 2e136cbeeb718..a45c28778ae7a 100644 --- a/apps/server/src/provider/claudeResetCredits.ts +++ b/apps/server/src/provider/claudeResetCredits.ts @@ -223,7 +223,7 @@ const readKeychainLogin = Effect.fn("claudeResetCredits.readKeychainLogin")( Effect.orElseSucceed(() => undefined), ); -/** customConfigDir is the CLAUDE_CONFIG_DIR Claude runs with, if any. */ +/** customConfigDir is the dir Claude keys its Keychain entry on, if any. */ const readAccessToken = Effect.fn("claudeResetCredits.readAccessToken")(function* ( configDir: string, customConfigDir: string | undefined, From 727dde2434f8b72d716bbd9a76fa365928b72f02 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 02:51:45 +0000 Subject: [PATCH 4/5] fix(server): reset redemptions lock on the shared Claude login --- apps/server/src/provider/Drivers/ClaudeDriver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e8b9673ddfbb1..61d7365114c69 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -338,7 +338,7 @@ export const ClaudeDriver: ProviderDriver ClaudeResetCredits.consumeClaudeResetCredit({ configDir: credentialsDir, From 6ae217e5924825e32176b09aeb151d2261c735c6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:29:18 +0000 Subject: [PATCH 5/5] test(server): keychain fake reads spawn args without a cast AI-assisted; verified with the tests and tools listed above. --- .../src/provider/claudeResetCredits.test.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/claudeResetCredits.test.ts b/apps/server/src/provider/claudeResetCredits.test.ts index 8b579d2ac71bd..a6f2842306798 100644 --- a/apps/server/src/provider/claudeResetCredits.test.ts +++ b/apps/server/src/provider/claudeResetCredits.test.ts @@ -7,7 +7,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HttpClient, HttpClientResponse, UrlParams } from "effect/http"; import { describe, expect, it } from "vite-plus/test"; @@ -40,12 +40,13 @@ const respond = (status: number, body: unknown) => ); const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); -// Answers `security find-generic-password` like the Keychain would, recording each lookup. -const keychain = (entries: Record, lookups: Array>) => +// 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 = (command as unknown as { args: ReadonlyArray }).args; - lookups.push(args); - const secret = entries[args[args.indexOf("-s") + 1]!]; + 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), @@ -187,7 +188,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { ), ); }); - const lookups: Array> = []; + const lookups: Array = []; const credits = yield* ClaudeResetCredits.readClaudeResetCredits( configDir, undefined, @@ -213,7 +214,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { "Claude Code-credentials": login("default-token"), [custom]: login("work-token"), }; - const lookups: Array> = []; + const lookups: Array = []; const read = (customConfigDir: string | undefined, token: string) => ClaudeResetCredits.readClaudeResetCredits(configDir, customConfigDir, "2.1.0").pipe( Effect.provideService(HostProcess.Platform, "darwin"), @@ -236,7 +237,7 @@ effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { availableCount: 1, nextCreditId: "grant_a", }); - expect(lookups.map((args) => args[args.indexOf("-s") + 1])).toEqual([ + expect(lookups).toEqual([ "Claude Code-credentials", custom, ClaudeResetCredits.claudeKeychainService("/Users/me/.claude-other"),