diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 37945bbb79af..288f6f97bd50 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -227,10 +228,29 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } +/** + * A signed-out CLI still initializes, reporting no token. So does an + * `apiKeyHelper` login, so this only nominates a probe for the + * `claude auth status` check that tells the two apart. + */ +function mayBeSignedOut(capabilities: ClaudeCapabilitiesProbe): boolean { + return ( + (capabilities.apiProvider ?? "firstParty") === "firstParty" && + capabilities.tokenSource === "none" && + !capabilities.apiKeySource + ); +} + +const decodeClaudeAuthStatus = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ loggedIn: Schema.Boolean })), +); + type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + /** Set when an API key authenticates; `tokenSource` is then `"none"`. */ + readonly apiKeySource?: string | undefined; /** * Active API backend reported by the SDK's `AccountInfo`. Anthropic OAuth * login only applies when `"firstParty"`; for Amazon Bedrock (`"bedrock"`) @@ -386,6 +406,7 @@ const probeClaudeCapabilities = ( readonly email?: string; readonly subscriptionType?: string; readonly tokenSource?: string; + readonly apiKeySource?: string; readonly apiProvider?: string; } | undefined; @@ -393,6 +414,7 @@ const probeClaudeCapabilities = ( email: account?.email, subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, + ...(account?.apiKeySource ? { apiKeySource: account.apiKeySource } : {}), apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), ...(usage ? { usage } : {}), @@ -413,6 +435,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, environment?: NodeJS.ProcessEnv, + cwd?: string, ) { const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, { @@ -421,6 +444,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { env: claudeEnvironment, shell: spawnCommand.shell, + ...(cwd ? { cwd } : {}), }); return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); @@ -587,6 +611,38 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + // Left as "authenticated", a signed-out CLI publishes authoritative + // `unsupported` limits, which hide the Usage row and drop turn updates. + // Asked from the probe's cwd so project-scoped auth settings still apply. + const signedOut = + mayBeSignedOut(capabilities) && + (yield* runClaudeCommand(claudeSettings, ["auth", "status"], resolvedEnvironment, cwd).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.map((result) => + Option.flatMap(result, (output) => decodeClaudeAuthStatus(output.stdout)).pipe( + Option.exists((status) => !status.loggedIn), + ), + ), + Effect.orElseSucceed(() => false), + )); + if (signedOut) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: claudeSettings.enabled, + checkedAt, + models, + slashCommands: dedupedSlashCommands, + skills, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unauthenticated" }, + message: "Claude is signed out. Run `claude` and use /login, then refresh.", + }, + }); + } + const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4f32cf9d2b5a..9b7b788457bd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -17,6 +17,7 @@ import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -194,14 +195,21 @@ export const ClaudeDriver: ProviderDriver = { // Per-instance capabilities cache: keyed on binary + resolved HOME so // account-specific probes never share auth metadata across instances. - const capabilitiesProbeCache = yield* Cache.make({ - capacity: 1, - timeToLive: CAPABILITIES_PROBE_TTL, - lookup: () => + // Failed probes and signed-out results are not kept, so the first + // refresh after `/login` reads the account and its usage. + const capabilitiesProbeCache = yield* Cache.makeWith( + () => probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), ), - }); + { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value?.usage !== undefined + ? CAPABILITIES_PROBE_TTL + : Duration.zero, + }, + ); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( effectiveConfig, cwd, @@ -229,6 +237,11 @@ export const ClaudeDriver: ProviderDriver = { ), ), ), + Effect.tap((provider) => + provider.auth.status === "unauthenticated" + ? Cache.invalidateAll(capabilitiesProbeCache) + : Effect.void, + ), Effect.map(stampIdentity), ), ), diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index d78de9ecf44e..c3298f3861ea 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -521,6 +521,45 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { }), ); + it.live("shows Claude's usage on the first refresh after signing back in", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const loginMarker = path.join(fixtures.claudeHomePath, "logged-in"); + const instanceId = ProviderInstanceId.make("claude_relogin"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: { + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [{ name: "T3_CLAUDE_LOGIN_MARKER", value: loginMarker, sensitive: false }], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }, + }); + const instance = yield* registry.getInstance(instanceId); + expect(instance).toBeDefined(); + + const signedOut = yield* instance!.snapshot.refresh; + expect(signedOut.auth.status).toBe("unauthenticated"); + expect(signedOut.usageLimits).toBeUndefined(); + + // `/login` in a terminal; T3 only learns of it on the next refresh. + yield* fs.writeFileString(loginMarker, ""); + const signedIn = yield* instance!.snapshot.refresh; + expect(signedIn.auth.status).toBe("authenticated"); + expect(signedIn.usageLimits?.unavailable).toBeUndefined(); + expect(signedIn.usageLimits?.windows.map((window) => window.id)).toEqual(["five_hour"]); + }).pipe(Effect.provide(layerTest)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index bf3e9906b16c..61b99045d337 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -149,8 +149,10 @@ type TestClaudeCapabilities = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; + readonly apiKeySource?: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly usage?: { readonly rate_limits_available: boolean; readonly rate_limits: null }; }; function claudeCapabilities(overrides: Partial = {}) { @@ -210,6 +212,7 @@ function recordingMockSpawnerLayer( const commands: Array<{ readonly args: ReadonlyArray; readonly env: NodeJS.ProcessEnv | undefined; + readonly cwd: string | undefined; }> = []; const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -218,9 +221,10 @@ function recordingMockSpawnerLayer( args: ReadonlyArray; options?: { readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; }; }; - commands.push({ args: cmd.args, env: cmd.options?.env }); + commands.push({ args: cmd.args, env: cmd.options?.env, cwd: cmd.options?.cwd }); return Effect.succeed(mockHandle(handler(cmd.args))); }), ); @@ -3028,6 +3032,106 @@ it.layer( ), ); + describe("when the CLI reports no token", () => { + const noToken = claudeCapabilities({ + tokenSource: "none", + apiProvider: "firstParty", + usage: { rate_limits_available: false, rate_limits: null }, + }); + const authStatusLayer = (loggedIn: boolean) => + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: `{"loggedIn":${loggedIn},"authMethod":"none"}\n`, + stderr: "", + // The real CLI exits 1 when signed out, still printing its JSON status. + code: loggedIn ? 0 : 1, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + + it.effect("reports a signed-out CLI as unauthenticated without usage limits", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(defaultClaudeSettings, noToken); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + assert.strictEqual(status.usageLimits, undefined); + }).pipe(Effect.provide(authStatusLayer(false))), + ); + + it.effect("keeps an apiKeyHelper login authenticated, asking from the probe cwd", () => { + // A project-scoped apiKeyHelper only applies from the probed workspace. + const spawner = recordingMockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"api_key_helper"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }); + return Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + noToken, + undefined, + "/workspace/project", + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.usageLimits?.unavailable?.reason, "unsupported"); + const authStatus = spawner.commands.find((c) => c.args.join(" ") === "auth status"); + assert.strictEqual(authStatus?.cwd, "/workspace/project"); + }).pipe(Effect.provide(spawner.layer)); + }); + + it.effect("keeps the previous result when auth status cannot answer", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(defaultClaudeSettings, noToken); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { stdout: "", stderr: "unknown command", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps an API key login authenticated without asking auth status", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + tokenSource: "none", + apiKeySource: "ANTHROPIC_API_KEY", + apiProvider: "firstParty", + usage: { rate_limits_available: false, rate_limits: null }, + }), + ); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + }).pipe( + Effect.provide( + layerMockSpawner((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + }); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs index 054ca04703e5..21a8fae31368 100755 --- a/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs +++ b/apps/server/src/provider/testing/ProviderInstanceRegistryLive.fixture.mjs @@ -5,10 +5,33 @@ if (process.argv.includes("--version")) { process.stdout.write("claude 2.1.219\n"); process.exit(0); } +// With T3_CLAUDE_LOGIN_MARKER set, the CLI is signed out until that file +// exists, answering the way a real signed-out CLI does. +const loginMarker = process.env.T3_CLAUDE_LOGIN_MARKER; +const signedOut = loginMarker !== undefined && !NodeFS.existsSync(loginMarker); +if (process.argv.includes("auth") && process.argv.includes("status")) { + process.stdout.write( + JSON.stringify({ loggedIn: !signedOut, authMethod: signedOut ? "none" : "claude.ai" }) + "\n", + ); + process.exit(signedOut ? 1 : 0); +} const lines = NodeReadline.createInterface({ input: process.stdin }); lines.on("line", (line) => { const message = JSON.parse(line); if (message.type !== "control_request") return; + if (message.request?.subtype === "get_usage" && signedOut) { + process.stdout.write( + JSON.stringify({ + type: "control_response", + response: { + subtype: "success", + request_id: message.request_id, + response: { session: {}, rate_limits_available: false, rate_limits: null }, + }, + }) + "\n", + ); + return; + } if (message.request?.subtype === "get_usage") { const marker = process.env.T3_CLAUDE_RESET_MARKER; if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && NodeFS.existsSync(marker)) { @@ -55,7 +78,9 @@ lines.on("line", (line) => { models: [], output_style: "default", available_output_styles: ["default"], - account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, + account: signedOut + ? { tokenSource: "none", apiProvider: "firstParty" } + : { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" }, }, }, }) + "\n",