Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"`)
Expand Down Expand Up @@ -386,13 +406,15 @@ const probeClaudeCapabilities = (
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiKeySource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
...(account?.apiKeySource ? { apiKeySource: account.apiKeySource } : {}),
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
Expand All @@ -413,6 +435,7 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* (
claudeSettings: ClaudeSettings,
args: ReadonlyArray<string>,
environment?: NodeJS.ProcessEnv,
cwd?: string,
) {
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
const spawnCommand = yield* resolveSpawnCommand(claudeSettings.binaryPath, args, {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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(
Comment thread
saphid marked this conversation as resolved.
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,
Expand Down
23 changes: 18 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -194,14 +195,21 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {

// 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,
Expand Down Expand Up @@ -229,6 +237,11 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
),
),
),
Effect.tap((provider) =>
provider.auth.status === "unauthenticated"
? Cache.invalidateAll(capabilitiesProbeCache)
: Effect.void,
),
Effect.map(stampIdentity),
),
),
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/provider/ProviderInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() =>
Expand Down
106 changes: 105 additions & 1 deletion apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerProviderSlashCommand>;
readonly usage?: { readonly rate_limits_available: boolean; readonly rate_limits: null };
};

function claudeCapabilities(overrides: Partial<TestClaudeCapabilities> = {}) {
Expand Down Expand Up @@ -210,6 +212,7 @@ function recordingMockSpawnerLayer(
const commands: Array<{
readonly args: ReadonlyArray<string>;
readonly env: NodeJS.ProcessEnv | undefined;
readonly cwd: string | undefined;
}> = [];
const layer = Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand All @@ -218,9 +221,10 @@ function recordingMockSpawnerLayer(
args: ReadonlyArray<string>;
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)));
}),
);
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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",
Expand Down
Loading