From 0c07993112901ff89e3ff3d51320e4f2cc84204b Mon Sep 17 00:00:00 2001 From: sys Date: Wed, 30 Sep 2026 20:44:05 +0300 Subject: [PATCH 1/3] feat(plugins): add experimental Claude OAuth provider --- server/plugins/build-in/claude-auth/README.md | 158 ++++++ .../build-in/claude-auth/assets/plugin.svg | 1 + server/plugins/build-in/claude-auth/auth.ts | 209 +++++++ .../build-in/claude-auth/claude_test.ts | 524 ++++++++++++++++++ server/plugins/build-in/claude-auth/deno.json | 18 + server/plugins/build-in/claude-auth/main.ts | 8 + .../plugins/build-in/claude-auth/messages.ts | 448 +++++++++++++++ .../build-in/claude-auth/messages_test.ts | 475 ++++++++++++++++ server/plugins/build-in/claude-auth/models.ts | 84 +++ server/plugins/build-in/claude-auth/oauth.ts | 97 ++++ .../plugins/build-in/claude-auth/plugin.json | 12 + .../plugins/build-in/claude-auth/provider.ts | 131 +++++ .../plugins/build-in/claude-auth/resources.ts | 32 ++ server/src/plugin/builtin.rs | 46 ++ server/src/plugin/worker.rs | 42 +- 15 files changed, 2279 insertions(+), 6 deletions(-) create mode 100644 server/plugins/build-in/claude-auth/README.md create mode 100644 server/plugins/build-in/claude-auth/assets/plugin.svg create mode 100644 server/plugins/build-in/claude-auth/auth.ts create mode 100644 server/plugins/build-in/claude-auth/claude_test.ts create mode 100644 server/plugins/build-in/claude-auth/deno.json create mode 100644 server/plugins/build-in/claude-auth/main.ts create mode 100644 server/plugins/build-in/claude-auth/messages.ts create mode 100644 server/plugins/build-in/claude-auth/messages_test.ts create mode 100644 server/plugins/build-in/claude-auth/models.ts create mode 100644 server/plugins/build-in/claude-auth/oauth.ts create mode 100644 server/plugins/build-in/claude-auth/plugin.json create mode 100644 server/plugins/build-in/claude-auth/provider.ts create mode 100644 server/plugins/build-in/claude-auth/resources.ts diff --git a/server/plugins/build-in/claude-auth/README.md b/server/plugins/build-in/claude-auth/README.md new file mode 100644 index 000000000..8c8b6e127 --- /dev/null +++ b/server/plugins/build-in/claude-auth/README.md @@ -0,0 +1,158 @@ +# Claude OAuth (experimental) + +Personal, unofficial subscription integration for Cursor BYOK. This is **not an Anthropic-supported +login method**. Anthropic's +[authentication policy](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) +prohibits offering third-party Claude.ai login and routing requests through subscription +credentials. Access may be denied or restricted without notice. An existing subscription does not +guarantee API access through this plugin. + +**Status:** local, mocked protocol tests pass. Browser consent, token exchange, profile/model +access, and inference have not been tested against a real account. This is an experimental +implementation, not a verified working subscription connection. + +## Structure + +```text +server/ +├── plugins/build-in/claude-auth/ Claude-specific integration +│ ├── plugin.json Manifest and allowed HTTPS hosts +│ ├── main.ts Provider and account registration +│ ├── oauth.ts Browser authorization and host-owned PKCE/callback integration +│ ├── auth.ts Token validation, account identity, and refresh coordination +│ ├── resources.ts Safe account presentation and manual credential refresh +│ ├── models.ts Paginated API model discovery and capability metadata +│ ├── provider.ts Invocation, credential patches, and error/limit classification +│ ├── messages.ts Messages serialization, streaming, and signed-thinking replay +│ ├── assets/plugin.svg Neutral plugin icon, not Anthropic branding +│ ├── claude_test.ts Mocked OAuth, resource, discovery, and provider tests +│ ├── messages_test.ts Mocked Messages protocol and history-prefix tests +│ └── deno.json Local SDK imports and development tasks +└── src/plugin/ Existing plugin host + ├── builtin.rs Embeds this plugin in release builds + └── worker.rs Keeps auxiliary HTTP fetches out of model-call recording +``` + +No frontend, database schema, or provider-independent conversation changes are required. Existing +modules are not moved or deleted. + +## Authentication and invocation + +```text +Add account + → host starts 127.0.0.1:/callback, generates state + PKCE + → plugin opens claude.ai/oauth/authorize with localhost:/callback + → user completes consent in browser + → host validates callback state + → plugin exchanges code + state + verifier using the exact advertised redirect + → plugin reads /api/oauth/profile for stable account and organization identity + → host persists private credentials, UI receives only the account display name + +Sync models + → /v1/models pagination using the selected account's valid token + → host stores the returned model catalog and capabilities + +Model invocation + → host supplies account snapshot + canonical request + → plugin refreshes expiring credentials (one refresh per old token within the worker) + → /v1/messages streaming request + → text / thinking / tool events → host → Cursor + → terminal result includes rotated credentials, even on handled inference errors + → host applies the credential/state patch +``` + +- Authorization/token constants match inspected Claude Code and current public client + implementations. +- The plugin requests only `user:profile user:inference`; acceptance of this reduced scope set has + not been verified with a live account. It does not request API-key creation, file-upload, or MCP + rights. +- `localhost` is used in both authorization and exchange. The existing host still binds IPv4 + loopback. The browser must run on the same machine as the server, with working localhost + resolution. +- Account identity comes from the profile endpoint, because token responses may omit identity + fields. +- Tokens stay in the host's private plugin storage. This is **not a promise of OS-keychain + encryption**; this plugin uses the existing host storage model. +- The worker-local refresh cache deduplicates concurrent refreshes and stale snapshots. It is not a + separate persistent store. A crash between token rotation and the host's final patch can require + signing in again. Running multiple app processes against the same profile is not supported. +- Model discovery does not rotate credentials, because the existing model-list contract cannot + persist a resource patch. If the token is expiring, refresh the account, then sync models. +- HTTP 401 receives one refresh/retry before output starts. Stream-level errors and partially + emitted responses are never retried by this plugin. HTTP 429 uses `Retry-After` for account + cooling. +- No static model fallback, credential-file import, API-key fallback, or usage-quota estimation is + provided. Available models are exactly what the upstream catalog returns; entitlement can still + differ at inference time. The fast-latency hint is not mapped to an unverified subscription + feature. + +## Upstream compatibility limitation + +The plugin sends Bearer authorization, `anthropic-version: 2023-06-01`, and +`anthropic-beta: oauth-2025-04-20`. It preserves user instructions and exact tool names. It **does +not impersonate the official Claude Code client**, inject an official-client system identity, or +forge a Claude CLI version. + +Current third-party clients sometimes add these identity transformations to avoid subscription API +rejections. Their necessity is not officially documented, but this is a significant compatibility +risk: authorization can succeed while inference is rejected. This implementation reports the refusal +instead of claiming that OAuth login guarantees usable inference. + +## Install and try + +Use a build of **this checkout**, not a copy of the plugin placed into an unpatched release. The +host recording fix is necessary: the upstream host could record a refresh-token request/response as +the first model request when detailed logging was enabled. + +The plugin is bundled automatically through `server/src/plugin/builtin.rs`. Debug builds discover it +from `server/plugins/build-in/claude-auth`. Build instructions are in the repository's +[contributing guide](../../../../CONTRIBUTING_EN.md). + +After starting that build: + +1. Initialize the plugin runtime if the application asks. +2. Open plugin management and select **Claude OAuth (experimental)**. +3. Choose **Sign in with Claude** and complete consent in the local browser. +4. Sync the model catalog and enable a returned model. +5. Run a short connectivity test before using a real conversation. + +No account login, token import, installation into the running app, or live model call is performed +by this change. A source checkout does not isolate application data: the host uses +`~/.cursor-byok-v3` by default. Use a separate OS account/environment for an isolated runtime +profile. + +Deleting an account removes the local resource through the host; there is no plugin-specific remote +revocation hook. Revoke the authorization through Anthropic's account controls when needed. + +## Verification + +From this directory with Deno 2 installed: + +```sh +deno task check +deno task lint +deno task fmt +deno task test +``` + +Tests use mocked networking and need no tokens or network permissions. They cover PKCE/state +forwarding, callback consistency, expiry, identity lookup, refresh-token rotation/coalescing, safe +errors, single-retry behavior, account cooling, pagination, append-only history, images, tool calls, +ordered signed/redacted thinking, usage merging, cancellation, and truncated streams. + +Host regression tests, from `server/` with the Rust toolchain and build prerequisites installed: + +```sh +cargo test --lib plugin::builtin::tests +cargo test --lib plugin::worker::tests +cargo fmt --all -- --check +``` + +## Protocol references + +- [Official policy](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) +- [Claude Code 2.1.63 implementation](https://unpkg.com/@anthropic-ai/claude-code@2.1.63/cli.js): + historical official evidence for variable-port localhost redirect and token/profile contracts. +- [pi OAuth implementation, pinned revision](https://github.com/badlogic/pi-mono/blob/db6cc71dc7b69202dc560e71106bb9dfd454e758/packages/ai/src/auth/oauth/anthropic.ts) +- [OpenCode OAuth implementation, pinned revision](https://github.com/ex-machina-co/opencode-anthropic-auth/blob/6e6d285b9895a013962a775e74a5616cce5e973e/src/auth.ts) +- [Anthropic model metadata types](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/models.ts) diff --git a/server/plugins/build-in/claude-auth/assets/plugin.svg b/server/plugins/build-in/claude-auth/assets/plugin.svg new file mode 100644 index 000000000..41b57fcab --- /dev/null +++ b/server/plugins/build-in/claude-auth/assets/plugin.svg @@ -0,0 +1 @@ + diff --git a/server/plugins/build-in/claude-auth/auth.ts b/server/plugins/build-in/claude-auth/auth.ts new file mode 100644 index 000000000..fbe5f722f --- /dev/null +++ b/server/plugins/build-in/claude-auth/auth.ts @@ -0,0 +1,209 @@ +import type { JsonValue, PluginContext } from "cursor-byok:plugin"; +import type { ResourceSnapshot } from "cursor-byok:resource"; + +export const RESOURCE_TYPE = "claude-account"; +export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; +export const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"; +export const SCOPES = ["user:profile", "user:inference"]; + +export type AccountData = { + accessToken: string; + refreshToken: string; + expiresAtMs: number; + accountId: string; + organizationId: string; + displayName: string; +}; + +export function object(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +export function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function apiHeaders(accessToken: string): Record { + return { + accept: "application/json", + authorization: `Bearer ${accessToken}`, + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + }; +} + +export function accountData(resource: ResourceSnapshot): AccountData { + const value = object(resource.privateData); + if ( + resource.type !== RESOURCE_TYPE || !value || + !text(value.accessToken) || !text(value.refreshToken) || + !text(value.accountId) || !text(value.organizationId) || !text(value.displayName) || + typeof value.expiresAtMs !== "number" || !Number.isFinite(value.expiresAtMs) || + value.expiresAtMs <= 0 + ) { + throw new Error("Claude account credentials are incomplete. Sign in again."); + } + return value as AccountData; +} + +export function isExpiring(data: AccountData, now = Date.now()): boolean { + return data.expiresAtMs <= now + 60_000; +} + +/** Do not expose token endpoint bodies: even error responses may contain credentials. */ +export class OAuthError extends Error { + constructor(readonly status: number, readonly invalidCredentials: boolean) { + super( + invalidCredentials + ? "Claude authorization was rejected. Sign in again." + : `Claude authorization failed (HTTP ${status}). Try again later.`, + ); + } +} + +export async function requestTokens( + params: Record, + context: PluginContext, +): Promise> { + context.signal.throwIfAborted(); + let response; + try { + response = await context.network.fetch(TOKEN_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify({ client_id: CLIENT_ID, ...params }), + }); + } catch { + context.signal.throwIfAborted(); + throw new Error("Could not reach Claude authorization. Check your connection and retry."); + } + let body: Record | null; + try { + body = object(JSON.parse(response.body)); + } catch { + body = null; + } + if (response.status < 200 || response.status >= 300) { + throw new OAuthError( + response.status, + response.status === 401 || response.status === 403 || + (response.status === 400 && body?.error === "invalid_grant"), + ); + } + if (!body) throw new Error("Claude returned invalid authorization data. Sign in again."); + return body; +} + +export function parseTokens( + body: Record, + previous?: AccountData, + now = Date.now(), +): AccountData { + const accessToken = text(body.access_token); + const refreshToken = text(body.refresh_token) ?? previous?.refreshToken; + const expiresIn = body.expires_in; + const account = object(body.account); + const organization = object(body.organization); + const accountId = text(account?.uuid) ?? previous?.accountId; + const organizationId = text(organization?.uuid) ?? previous?.organizationId; + const tokenType = text(body.token_type); + if ( + !accessToken || !refreshToken || !accountId || !organizationId || + typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || + !Number.isSafeInteger(now + expiresIn * 1000) || + (tokenType !== null && tokenType.toLowerCase() !== "bearer") + ) { + throw new Error("Claude returned incomplete authorization data. Sign in again."); + } + if ( + previous && + (accountId !== previous.accountId || organizationId !== previous.organizationId) + ) { + throw new Error("Claude returned a different account during refresh. Sign in again."); + } + if (typeof body.scope === "string" && !body.scope.split(/\s+/).includes("user:inference")) { + throw new Error("Claude did not grant model access. Sign in again and approve model access."); + } + return { + accessToken, + refreshToken, + expiresAtMs: now + expiresIn * 1000, + accountId, + organizationId, + displayName: text(account?.email_address) ?? previous?.displayName ?? accountId, + }; +} + +/** Read stable identity from the profile endpoint, not optional token response metadata. */ +export async function createAccount( + tokens: Record, + context: PluginContext, +): Promise { + const accessToken = text(tokens.access_token); + if (!accessToken) throw new Error("Claude returned no access token. Sign in again."); + context.signal.throwIfAborted(); + let response; + try { + response = await context.network.fetch("https://api.anthropic.com/api/oauth/profile", { + headers: apiHeaders(accessToken), + }); + } catch { + context.signal.throwIfAborted(); + throw new Error("Could not load the Claude account. Check your connection and sign in again."); + } + if (response.status < 200 || response.status >= 300) { + throw new Error(`Claude account lookup failed (HTTP ${response.status}). Sign in again.`); + } + let profile: Record | null; + try { + profile = object(JSON.parse(response.body)); + } catch { + profile = null; + } + const account = object(profile?.account); + return parseTokens({ + ...tokens, + account: { uuid: account?.uuid, email_address: account?.email }, + organization: profile?.organization, + }); +} + +/** + * Worker-local single flight for rotating refresh tokens. Retain the outcome until its + * expiry so calls holding the same old host snapshot receive the same replacement. + * The host remains the persistence owner; every consumer returns the replacement in a patch. + */ +const refreshes = new Map }>(); + +export function refreshTokens(data: AccountData, context: PluginContext): Promise { + context.signal.throwIfAborted(); + const now = Date.now(); + for (const [key, value] of refreshes) { + if (value.expiresAtMs <= now) refreshes.delete(key); + } + const existing = refreshes.get(data.refreshToken); + if (existing) return existing.result; + const entry = { + expiresAtMs: Infinity, + result: requestTokens( + { grant_type: "refresh_token", refresh_token: data.refreshToken }, + context, + ) + .then((body) => { + const refreshed = parseTokens(body, data); + entry.expiresAtMs = refreshed.expiresAtMs - 60_000; + return refreshed; + }).catch((error: unknown) => { + refreshes.delete(data.refreshToken); + throw error; + }), + }; + refreshes.set(data.refreshToken, entry); + return entry.result; +} + +export function privateData(data: AccountData): JsonValue { + return { ...data }; +} diff --git a/server/plugins/build-in/claude-auth/claude_test.ts b/server/plugins/build-in/claude-auth/claude_test.ts new file mode 100644 index 000000000..929695054 --- /dev/null +++ b/server/plugins/build-in/claude-auth/claude_test.ts @@ -0,0 +1,524 @@ +import type { + JsonValue, + NetworkEventStream, + NetworkRequestInit, + NetworkResponse, + PluginContext, +} from "cursor-byok:plugin"; +import type { LlmRequest, ModelEvent } from "cursor-byok:provider"; +import type { ResourceSnapshot } from "cursor-byok:resource"; +import { + accountData, + CLIENT_ID, + OAuthError, + parseTokens, + refreshTokens, + RESOURCE_TYPE, + TOKEN_URL, +} from "./auth.ts"; +import { claudeOAuth } from "./oauth.ts"; +import { claudeAccounts } from "./resources.ts"; +import { claudeModels } from "./models.ts"; +import { claudeProvider, retryAtMs } from "./provider.ts"; + +function assert(condition: unknown, message = "assertion failed"): asserts condition { + if (!condition) throw new Error(message); +} +function equal(actual: unknown, expected: unknown) { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); +} +async function rejects(fn: () => unknown, contains?: string) { + try { + await fn(); + } catch (error) { + assert(error instanceof Error); + if (contains) assert(error.message.includes(contains), error.message); + return error; + } + throw new Error("expected rejection"); +} +function response(body: unknown, status = 200): NetworkResponse { + return { status, headers: {}, body: JSON.stringify(body) }; +} +function context(handlers: { + fetch?: (url: string, init?: NetworkRequestInit) => NetworkResponse | Promise; + stream?: (url: string, init?: NetworkRequestInit) => NetworkEventStream; +} = {}): PluginContext { + return { + signal: new AbortController().signal, + network: { + fetch: (url, init) => { + assert(handlers.fetch, "unexpected fetch"); + return Promise.resolve(handlers.fetch(url, init)); + }, + stream: (url, init) => { + assert(handlers.stream, "unexpected stream"); + return Promise.resolve(handlers.stream(url, init)); + }, + }, + }; +} +let sequence = 0; +function resource(expired = false): ResourceSnapshot { + const id = ++sequence; + return { + id: `resource-${id}`, + type: RESOURCE_TYPE, + key: "claude:org:account", + state: { status: "ready" }, + privateData: { + accessToken: `access-secret-${id}`, + refreshToken: `refresh-secret-${id}`, + expiresAtMs: Date.now() + (expired ? -1000 : 3_600_000), + accountId: "account", + organizationId: "org", + displayName: "person@example.com", + }, + }; +} +function tokens(extra: Record = {}) { + return { + access_token: "new-access-secret", + refresh_token: "new-refresh-secret", + expires_in: 3600, + token_type: "Bearer", + scope: "user:profile user:inference", + account: { uuid: "account", email_address: "person@example.com" }, + organization: { uuid: "org" }, + ...extra, + }; +} +function request(): LlmRequest { + return { + instructions: "Help with code.", + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + tools: [], + reasoning: { enabled: false, effort: null }, + latency: "standard", + maxOutputTokens: 1024, + cacheKey: "conversation", + }; +} +async function* lines(values: string[]) { + for (const value of values) yield value; +} +function stream(status = 200, headers: Record = {}): NetworkEventStream { + const events = [ + { type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + return { + status, + headers, + lines: lines( + status === 200 + ? events.flatMap((event) => [`data: ${JSON.stringify(event)}`, ""]) + : ["sensitive upstream error"], + ), + }; +} +const model = { id: "claude-test", displayName: "Claude test" }; + +Deno.test("OAuth delegates PKCE and state to the host and uses a localhost callback", async () => { + const begin = await claudeOAuth.begin({ + redirectUri: "http://127.0.0.1:43210/callback", + state: "expected-state", + codeChallenge: "host-challenge", + }, context()); + const url = new URL(begin.authorizationUrl); + equal(url.origin + url.pathname, "https://claude.ai/oauth/authorize"); + equal(url.searchParams.get("redirect_uri"), "http://localhost:43210/callback"); + equal(url.searchParams.get("client_id"), CLIENT_ID); + equal(url.searchParams.get("code_challenge_method"), "S256"); + equal(url.searchParams.get("code_challenge"), "host-challenge"); + equal(url.searchParams.get("state"), "expected-state"); + assert(!url.searchParams.get("scope")?.includes("org:create_api_key")); + const drafts = await claudeOAuth.complete( + begin.session, + { + code: "authorization-code", + codeVerifier: "host-verifier", + redirectUri: "http://127.0.0.1:43210/callback", + }, + context({ + fetch: (url, init) => { + if (url === "https://api.anthropic.com/api/oauth/profile") { + equal(init?.headers?.authorization, "Bearer new-access-secret"); + return response({ + account: { uuid: "account", email: "person@example.com" }, + organization: { uuid: "org" }, + }); + } + equal(url, TOKEN_URL); + equal(init?.headers?.["content-type"], "application/json"); + const body = JSON.parse(init?.body ?? "{}"); + equal(body.state, "expected-state"); + equal(body.code_verifier, "host-verifier"); + equal(body.redirect_uri, "http://localhost:43210/callback"); + return response(tokens({ account: undefined, organization: undefined })); + }, + }), + ); + equal(drafts[0].key, "claude:org:account"); + const view = claudeAccounts.present({ ...resource(), ...drafts[0] }); + assert(!JSON.stringify(view).includes("secret")); + equal(view.displayName, "person@example.com"); +}); + +Deno.test("OAuth rejects expired or changed sessions before network access", async () => { + const input = { + code: "code", + codeVerifier: "verifier", + redirectUri: "http://127.0.0.1:12345/callback", + }; + await rejects( + () => + claudeOAuth.complete( + { state: "state", redirectUri: "http://localhost:12345/callback", expiresAtMs: 1 }, + input, + context(), + ), + "expired", + ); + await rejects( + () => + claudeOAuth.complete( + { + state: "state", + redirectUri: "http://localhost:9999/callback", + expiresAtMs: Date.now() + 60_000, + }, + input, + context(), + ), + "changed", + ); + await rejects( + () => + claudeOAuth.begin({ + redirectUri: "https://example.com/callback", + state: "state", + codeChallenge: "challenge", + }, context()), + "local callback", + ); +}); + +Deno.test("token validation rejects missing identity, invalid expiry, and missing inference scope", async () => { + for ( + const invalid of [{ account: null }, { expires_in: 0 }, { expires_in: Infinity }, { + token_type: "Basic", + }, { scope: "user:profile" }] + ) { + await rejects(() => parseTokens(tokens(invalid))); + } + const data = parseTokens(tokens(), undefined, 1000); + equal(data.expiresAtMs, 3_601_000); + const refreshed = parseTokens( + tokens({ account: undefined, organization: undefined, refresh_token: undefined }), + data, + 2000, + ); + equal(refreshed.accountId, data.accountId); + equal(refreshed.refreshToken, data.refreshToken); + await rejects( + () => parseTokens(tokens({ account: { uuid: "different" } }), data), + "different account", + ); +}); + +Deno.test("OAuth errors never expose token response bodies", async () => { + const error = await rejects(() => + refreshTokens( + accountData(resource(true)), + context({ + fetch: () => + response({ + error: "invalid_grant", + error_description: "access-secret should never be shown", + }, 400), + }), + ) + ); + assert(error instanceof OAuthError && error.invalidCredentials); + assert(!error.message.includes("access-secret")); +}); + +Deno.test("refresh coalesces concurrent and stale snapshots without repeating token rotation", async () => { + const data = accountData(resource(true)); + let calls = 0; + const ctx = context({ + fetch: () => { + calls++; + return response(tokens()); + }, + }); + const [first, second] = await Promise.all([refreshTokens(data, ctx), refreshTokens(data, ctx)]); + equal(first, second); + equal(await refreshTokens(data, ctx), first); + equal(calls, 1); +}); + +Deno.test("transient refresh failures allow a later retry", async () => { + const data = accountData(resource(true)); + await rejects(() => refreshTokens(data, context({ fetch: () => response({}, 503) }))); + const refreshed = await refreshTokens(data, context({ fetch: () => response(tokens()) })); + equal(refreshed.accessToken, "new-access-secret"); +}); + +Deno.test("provider uses Bearer OAuth and emits a complete response", async () => { + const events: ModelEvent[] = []; + const selected = resource(); + const result = await claudeProvider.invoke( + { model, resource: selected, request: request() }, + { emit: (event) => events.push(event) }, + context({ + stream: (url, init) => { + equal(url, "https://api.anthropic.com/v1/messages"); + equal(init?.headers?.authorization, `Bearer ${accountData(selected).accessToken}`); + equal(init?.headers?.["anthropic-beta"], "oauth-2025-04-20"); + assert(!init?.headers?.["x-api-key"]); + return stream(); + }, + }), + ); + equal(result.status, "completed"); + equal(events.at(-1), { type: "done", reason: "stop" }); +}); + +Deno.test("expired token is refreshed before inference and persisted even on HTTP 429", async () => { + let fetches = 0; + const result = await claudeProvider.invoke( + { model, resource: resource(true), request: request() }, + { emit: () => {} }, + context({ + fetch: (_url, init) => { + fetches++; + equal(JSON.parse(init?.body ?? "{}").grant_type, "refresh_token"); + return response(tokens()); + }, + stream: (_url, init) => { + equal(init?.headers?.authorization, "Bearer new-access-secret"); + return stream(429, { "retry-after": "120" }); + }, + }), + ); + equal(fetches, 1); + equal(result.status, "resource-error"); + assert(result.patch?.state?.status === "cooling"); + assert(result.patch.state.retryAtMs! > Date.now() + 119_000); + equal((result.patch.privateData as Record).refreshToken, "new-refresh-secret"); +}); + +Deno.test("HTTP 401 retries once after refresh and never loops", async () => { + let requests = 0; + let refreshes = 0; + const result = await claudeProvider.invoke( + { model, resource: resource(), request: request() }, + { emit: () => {} }, + context({ + fetch: () => { + refreshes++; + return response(tokens()); + }, + stream: () => { + requests++; + return stream(401); + }, + }), + ); + equal(requests, 2); + equal(refreshes, 1); + equal(result.status, "resource-error"); + equal(result.patch?.state?.status, "invalid"); + assert(result.patch?.privateData); +}); + +Deno.test("HTTP 403 reports denied access without a refresh or silent fallback", async () => { + const result = await claudeProvider.invoke({ model, resource: resource(), request: request() }, { + emit: () => {}, + }, context({ stream: () => stream(403) })); + equal(result.status, "resource-error"); + assert(result.status === "resource-error" && result.message.includes("blocked")); +}); + +Deno.test("HTTP 503 refresh failure does not invalidate an account", async () => { + const result = await claudeProvider.invoke( + { model, resource: resource(true), request: request() }, + { emit: () => {} }, + context({ + fetch: () => response({ error: "upstream" }, 503), + }), + ); + equal(result.status, "request-error"); + assert(!result.patch); +}); + +Deno.test("stream failure after refresh retains replacement credentials without replaying partial output", async () => { + let requests = 0; + const result = await claudeProvider.invoke( + { model, resource: resource(true), request: request() }, + { emit: () => {} }, + context({ + fetch: () => response(tokens()), + stream: () => { + requests++; + return { + status: 200, + headers: {}, + lines: lines([ + 'data: {"type":"message_start","message":{}}', + "", + 'data: {"type":"error","error":{"type":"api_error","message":"access-secret"}}', + "", + ]), + }; + }, + }), + ); + equal(result.status, "request-error"); + assert(result.patch?.privateData); + equal(requests, 1); + assert(result.status !== "completed" && !result.message.includes("access-secret")); +}); + +Deno.test("retry-after supports seconds, HTTP dates, and malformed headers", () => { + equal(retryAtMs({ "Retry-After": "30" }, 1000), 31_000); + equal(retryAtMs({ "retry-after": "Thu, 01 Jan 1970 00:01:00 GMT" }, 1000), 60_000); + equal(retryAtMs({ "retry-after": "invalid" }, 1000), 61_000); +}); + +Deno.test("models paginate account-visible catalog with capability metadata", async () => { + let pages = 0; + const models = await claudeModels.list( + { resource: resource() }, + context({ + fetch: (url) => { + pages++; + const parsed = new URL(url); + if (pages === 1) { + assert(!parsed.searchParams.has("after_id")); + return response({ + data: [{ + id: "claude-a", + display_name: "Claude A", + max_tokens: 64000, + capabilities: { + image_input: { supported: true }, + thinking: { supported: true, types: { adaptive: { supported: true } } }, + effort: { supported: true, high: { supported: true } }, + }, + }], + has_more: true, + last_id: "claude-a", + }); + } + equal(parsed.searchParams.get("after_id"), "claude-a"); + return response({ + data: [{ id: "claude-b", display_name: "Claude B" }], + has_more: false, + last_id: "claude-b", + }); + }, + }), + ); + equal(pages, 2); + equal(models.map((model) => model.id), ["claude-a", "claude-b"]); + equal(models[0].privateData, { thinking: "adaptive", efforts: ["high"] }); + equal(models[0].capabilities, { images: true }); +}); + +Deno.test("model sync refuses to rotate unpersistable credentials or fabricate a catalog", async () => { + await rejects( + () => claudeModels.list({ resource: resource(true) }, context()), + "Refresh the account", + ); + await rejects( + () => claudeModels.list({ resource: resource() }, context({ fetch: () => response({}, 403) })), + "403", + ); + await rejects( + () => + claudeModels.list( + { resource: resource() }, + context({ fetch: () => response({ data: [], has_more: true, last_id: "same" }) }), + ), + "pagination", + ); +}); + +Deno.test("manual refresh persists rotated tokens and marks only invalid grants invalid", async () => { + const refreshed = await claudeAccounts.refresh!( + resource(), + context({ fetch: () => response(tokens()) }), + ); + equal(refreshed.state, { status: "ready" }); + assert(refreshed.privateData); + const invalid = await claudeAccounts.refresh!( + resource(), + context({ fetch: () => response({ error: "invalid_grant" }, 400) }), + ); + equal(invalid.state?.status, "invalid"); + await rejects(() => + claudeAccounts.refresh!(resource(), context({ fetch: () => response({}, 503) })) + ); +}); + +Deno.test("SSE authentication errors do not trigger the HTTP refresh retry", async () => { + let requests = 0; + const result = await claudeProvider.invoke( + { model, resource: resource(), request: request() }, + { emit: () => {} }, + context({ + stream: () => { + requests++; + return { + status: 200, + headers: {}, + lines: lines(['data: {"type":"error","error":{"type":"authentication_error"}}', ""]), + }; + }, + }), + ); + equal(result.status, "resource-error"); + equal(result.patch?.state?.status, "invalid"); + equal(requests, 1); +}); + +Deno.test("HTTP failures close the response iterator before completing", async () => { + let closed = false; + async function* failedBody() { + try { + yield "sensitive body"; + } finally { + closed = true; + } + } + await claudeProvider.invoke( + { model, resource: resource(), request: request() }, + { emit: () => {} }, + context({ + stream: () => ({ status: 503, headers: {}, lines: failedBody() }), + }), + ); + assert(closed, "failed HTTP response was left open"); +}); + +Deno.test("cancelled invocation makes no network calls", async () => { + const ctx = context(); + ctx.signal = AbortSignal.abort(); + const result = await claudeProvider.invoke( + { model, resource: resource(true), request: request() }, + { emit: () => {} }, + ctx, + ); + equal(result.status, "request-error"); + assert(!result.patch); +}); diff --git a/server/plugins/build-in/claude-auth/deno.json b/server/plugins/build-in/claude-auth/deno.json new file mode 100644 index 000000000..d06f6fa7f --- /dev/null +++ b/server/plugins/build-in/claude-auth/deno.json @@ -0,0 +1,18 @@ +{ + "imports": { + "cursor-byok:plugin": "../../../src/plugin/sdk/plugin.ts", + "cursor-byok:provider": "../../../src/plugin/sdk/provider.ts", + "cursor-byok:model": "../../../src/plugin/sdk/model.ts", + "cursor-byok:resource": "../../../src/plugin/sdk/resource.ts" + }, + "tasks": { + "check": "deno check main.ts", + "test": "deno test --no-remote", + "lint": "deno lint", + "fmt": "deno fmt --check" + }, + "fmt": { + "lineWidth": 100, + "exclude": ["assets"] + } +} diff --git a/server/plugins/build-in/claude-auth/main.ts b/server/plugins/build-in/claude-auth/main.ts new file mode 100644 index 000000000..13a5d99fc --- /dev/null +++ b/server/plugins/build-in/claude-auth/main.ts @@ -0,0 +1,8 @@ +import { defineProviderPlugin } from "cursor-byok:plugin"; +import { claudeProvider } from "./provider.ts"; +import { claudeAccounts } from "./resources.ts"; + +export default defineProviderPlugin({ + providers: [claudeProvider], + resources: [claudeAccounts], +}); diff --git a/server/plugins/build-in/claude-auth/messages.ts b/server/plugins/build-in/claude-auth/messages.ts new file mode 100644 index 000000000..12cbb7118 --- /dev/null +++ b/server/plugins/build-in/claude-auth/messages.ts @@ -0,0 +1,448 @@ +import type { JsonValue, PluginContext } from "cursor-byok:plugin"; +import type { ModelSnapshot } from "cursor-byok:model"; +import type { LlmContentPart, LlmRequest, ModelUsage, ProviderOutput } from "cursor-byok:provider"; + +const REPLAY_KIND = "claude_oauth"; + +/** Safe classification only: upstream response bodies and error messages are never retained. */ +export class HttpError extends Error { + readonly headers: Record; + constructor( + readonly status: number, + headers: Record, + readonly errorType?: string, + ) { + super(`Anthropic Messages request failed (HTTP ${status})`); + this.name = "HttpError"; + // Retain retry timing, not arbitrary upstream headers that may contain credentials. + this.headers = Object.fromEntries( + Object.entries(headers) + .filter(([name]) => name.toLowerCase() === "retry-after") + .map(([name, value]) => [name.toLowerCase(), value]), + ); + } +} + +type ObjectValue = Record; +function object(value: unknown): ObjectValue | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as ObjectValue + : null; +} +function invalid(): never { + throw new Error("Anthropic Messages returned an invalid or incomplete stream"); +} +function string(value: unknown): string { + if (typeof value !== "string") invalid(); + return value; +} +function count(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} +function part(value: LlmContentPart): JsonValue { + return value.type === "text" ? { type: "text", text: value.text } : { + type: "image", + source: { type: "base64", media_type: value.mediaType, data: value.dataBase64 }, + }; +} +function signedThinking(value: JsonValue): boolean { + const block = object(value); + return !!block && ((block.type === "thinking" && typeof block.thinking === "string" && + typeof block.signature === "string" && block.signature.length > 0) || + (block.type === "redacted_thinking" && typeof block.data === "string")); +} + +export function buildMessagesBody(model: ModelSnapshot, request: LlmRequest): ObjectValue { + const messages: JsonValue[] = request.messages.map((message): JsonValue => { + if (message.role === "assistant") { + const replay = message.replayState; + const blocks = object(replay?.value)?.blocks; + let content: JsonValue[]; + if (replay?.providerKind === REPLAY_KIND) { + if (!Array.isArray(blocks)) throw new Error("Invalid Claude OAuth replay state"); + // Full ordered blocks are authoritative, including text between thinking and tools. + content = blocks; + } else { + content = replay?.providerKind === "anthropic" && Array.isArray(blocks) + ? blocks.filter(signedThinking) + : []; + if (message.text) content.push({ type: "text", text: message.text }); + content.push(...message.toolCalls.map((tool): JsonValue => ({ + type: "tool_use", + id: tool.callId, + name: tool.name, + input: tool.arguments, + }))); + } + return { role: "assistant", content }; + } + if (message.role === "tool") { + return { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: message.callId, + is_error: message.isError, + content: message.parts.length ? message.parts.map(part) : message.content, + }], + }; + } + // Historical system messages are conversation context, not stable instructions. + return { role: "user", content: message.content.map(part) }; + }); + const maximum = count(model.maxOutputTokens); + const requested = count(request.maxOutputTokens); + const maxTokens = Math.min( + requested && requested > 0 ? requested : 8192, + maximum && maximum > 0 ? maximum : Number.MAX_SAFE_INTEGER, + ); + const body: ObjectValue = { + model: model.id, + system: request.instructions, + messages, + max_tokens: maxTokens, + stream: true, + cache_control: { type: "ephemeral" }, + }; + if (request.tools.length) { + body.tools = request.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + })); + } + const metadata = object(model.privateData); + if (request.reasoning.enabled && metadata?.thinking === "adaptive") { + body.thinking = { type: "adaptive" }; + if ( + request.reasoning.effort && Array.isArray(metadata.efforts) && + metadata.efforts.includes(request.reasoning.effort) + ) { + body.output_config = { effort: request.reasoning.effort }; + } + } else if (request.reasoning.enabled && metadata?.thinking === "enabled" && maxTokens > 1024) { + body.thinking = { + type: "enabled", + budget_tokens: Math.min(8192, Math.max(1024, Math.floor(maxTokens / 2)), maxTokens - 1), + }; + } + return body; +} + +/** Race host operations too: cancellation must not wait for another upstream line. */ +function abortable(operation: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const abort = () => { + cleanup(); + reject(signal.reason); + }; + const cleanup = () => signal.removeEventListener("abort", abort); + signal.addEventListener("abort", abort, { once: true }); + operation.then((result) => { + cleanup(); + resolve(result); + }, (error) => { + cleanup(); + reject(error); + }); + }); +} + +async function* events( + lines: AsyncIterable, + signal: AbortSignal, +): AsyncGenerator { + const iterator = lines[Symbol.asyncIterator](); + let data: string[] = []; + let eventName = ""; + try { + while (true) { + signal.throwIfAborted(); + const item = await abortable(iterator.next(), signal); + signal.throwIfAborted(); + if (item.done) break; + const line = item.value.replace(/\r$/, ""); + if (line === "") { + if (data.length) { + let value: ObjectValue | null; + try { + value = object(JSON.parse(data.join("\n"))); + } catch { + invalid(); + } + if ( + !value || typeof value.type !== "string" || + (eventName && eventName !== "message" && eventName !== value.type) + ) invalid(); + yield value; + } + data = []; + eventName = ""; + } else if (!line.startsWith(":")) { + const colon = line.indexOf(":"); + const field = colon < 0 ? line : line.slice(0, colon); + const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, ""); + if (field === "data") data.push(value); + else if (field === "event") eventName = value; + } + } + // An unterminated SSE event is not a terminal message, even if its JSON looks complete. + if (data.length) invalid(); + } finally { + // Returning a pending async generator can itself hang after cancellation. + try { + void iterator.return?.().catch(() => {}); + } catch { /* Preserve original failure. */ } + } +} + +type BlockState = { value: ObjectValue; arguments: string; hasArguments: boolean }; +function mergeUsage(usage: ModelUsage, value: JsonValue | undefined): void { + const update = object(value); + if (!update) invalid(); + const fields = { + input_tokens: "inputTokens", + output_tokens: "outputTokens", + cache_read_input_tokens: "cacheReadTokens", + cache_creation_input_tokens: "cacheWriteTokens", + } as const; + for (const [upstream, local] of Object.entries(fields)) { + if (update[upstream] !== undefined) { + const next = count(update[upstream]); + if (next === null) invalid(); + usage[local] = next; + } + } + if (usage.inputTokens !== null && usage.outputTokens !== null) { + usage.totalTokens = usage.inputTokens + usage.outputTokens + + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0); + } +} +function streamError(value: ObjectValue, headers: Record): HttpError { + const errorType = object(value.error)?.type; + const statuses: Record = { + invalid_request_error: 400, + authentication_error: 401, + permission_error: 403, + not_found_error: 404, + request_too_large: 413, + rate_limit_error: 429, + api_error: 500, + overloaded_error: 529, + }; + const known = typeof errorType === "string" && Object.hasOwn(statuses, errorType) + ? errorType + : "api_error"; + return new HttpError(statuses[known], headers, known); +} + +export async function streamMessages( + model: ModelSnapshot, + request: LlmRequest, + headers: Record, + output: ProviderOutput, + context: PluginContext, +): Promise { + context.signal.throwIfAborted(); + const response = await abortable( + context.network.stream("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + ...headers, + accept: "text/event-stream", + "content-type": "application/json", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(buildMessagesBody(model, request)), + }), + context.signal, + ); + context.signal.throwIfAborted(); + if (response.status < 200 || response.status >= 300) { + // Start then close the host's lazy iterator, otherwise its stream handle stays open. + const iterator = response.lines[Symbol.asyncIterator](); + try { + await abortable(iterator.next(), context.signal); + } catch { + context.signal.throwIfAborted(); + } finally { + try { + void iterator.return?.().catch(() => {}); + } catch { /* Preserve HTTP status. */ } + } + throw new HttpError(response.status, response.headers); + } + const blocks: ObjectValue[] = []; + let active: BlockState | null = null; + let activeIndex = -1; + let started = false; + let sawTool = false; + let finishing = false; + let finish: "stop" | "length" | "tool-use" | null = null; + let hasUsage = false; + const usage: ModelUsage = { + inputTokens: null, + outputTokens: null, + totalTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningTokens: null, + }; + for await (const event of events(response.lines, context.signal)) { + context.signal.throwIfAborted(); + if (event.type === "ping") continue; + if (event.type === "error") throw streamError(event, response.headers); + if (event.type === "message_start") { + if (started || !object(event.message)) invalid(); + started = true; + const initial = object(event.message)!; + if ( + initial.content !== undefined && + (!Array.isArray(initial.content) || initial.content.length !== 0) + ) invalid(); + if (initial.usage !== undefined) { + mergeUsage(usage, initial.usage); + hasUsage = true; + } + continue; + } + if (!started) invalid(); + switch (event.type) { + case "content_block_start": { + const index = count(event.index); + const value = object(event.content_block); + if (active || finishing || index === null || index !== blocks.length || !value) invalid(); + activeIndex = index; + active = { value: { ...value }, arguments: "", hasArguments: false }; + switch (value.type) { + case "text": + string(value.text); + output.emit({ type: "text-start" }); + if (value.text) output.emit({ type: "text-delta", text: string(value.text) }); + break; + case "thinking": + string(value.thinking); + if (value.signature !== undefined) string(value.signature); + active.value.signature ??= ""; + output.emit({ type: "thinking-start" }); + if (value.thinking) { + output.emit({ type: "thinking-delta", text: string(value.thinking) }); + } + break; + case "redacted_thinking": + string(value.data); + break; + case "tool_use": + if (!string(value.id) || !string(value.name) || !object(value.input)) invalid(); + sawTool = true; + output.emit({ + type: "tool-call-start", + index, + callId: string(value.id), + name: string(value.name), + }); + break; + default: + invalid(); + } + break; + } + case "content_block_delta": { + if (!active || event.index !== activeIndex) invalid(); + const delta = object(event.delta); + if (!delta) invalid(); + const block = active.value; + if (block.type === "text" && delta.type === "text_delta") { + const text = string(delta.text); + block.text = string(block.text) + text; + output.emit({ type: "text-delta", text }); + } else if (block.type === "thinking" && delta.type === "thinking_delta") { + const text = string(delta.thinking); + block.thinking = string(block.thinking) + text; + output.emit({ type: "thinking-delta", text }); + } else if (block.type === "thinking" && delta.type === "signature_delta") { + block.signature = string(block.signature) + string(delta.signature); + } else if (block.type === "tool_use" && delta.type === "input_json_delta") { + const text = string(delta.partial_json); + active.arguments += text; + if (text) active.hasArguments = true; + output.emit({ type: "tool-call-arguments-delta", index: activeIndex, delta: text }); + } else invalid(); + break; + } + case "content_block_stop": { + if (!active || event.index !== activeIndex) invalid(); + const block = active.value; + if (block.type === "text") output.emit({ type: "text-end" }); + else if (block.type === "thinking") { + if (!signedThinking(block)) invalid(); + output.emit({ type: "thinking-end" }); + } else if (block.type === "tool_use") { + if (active.hasArguments) { + try { + block.input = JSON.parse(active.arguments); + } catch { + invalid(); + } + if (!object(block.input)) invalid(); + } else { + output.emit({ + type: "tool-call-arguments-delta", + index: activeIndex, + delta: JSON.stringify(block.input), + }); + } + output.emit({ type: "tool-call-end", index: activeIndex }); + } + blocks.push(block); + active = null; + break; + } + case "message_delta": { + if (active) invalid(); + finishing = true; + const delta = object(event.delta); + if (!delta) invalid(); + if (event.usage !== undefined) { + mergeUsage(usage, event.usage); + hasUsage = true; + } + if (delta.stop_reason !== undefined && delta.stop_reason !== null) { + switch (delta.stop_reason) { + case "max_tokens": + case "model_context_window_exceeded": + finish = "length"; + break; + case "tool_use": + finish = "tool-use"; + break; + case "end_turn": + case "stop_sequence": + case "pause_turn": + case "refusal": + finish = "stop"; + break; + default: + invalid(); + } + } + break; + } + case "message_stop": + if (active || !finish) invalid(); + output.emit({ type: "replay-state", providerKind: REPLAY_KIND, value: { blocks } }); + if (hasUsage) output.emit({ type: "usage", usage }); + context.signal.throwIfAborted(); + output.emit({ + type: "done", + reason: finish === "length" ? finish : sawTool ? "tool-use" : finish, + }); + return; + default: + invalid(); + } + } + context.signal.throwIfAborted(); + invalid(); +} diff --git a/server/plugins/build-in/claude-auth/messages_test.ts b/server/plugins/build-in/claude-auth/messages_test.ts new file mode 100644 index 000000000..17e268db5 --- /dev/null +++ b/server/plugins/build-in/claude-auth/messages_test.ts @@ -0,0 +1,475 @@ +import type { JsonValue, NetworkEventStream, PluginContext } from "cursor-byok:plugin"; +import type { ModelSnapshot } from "cursor-byok:model"; +import type { LlmRequest, ModelEvent } from "cursor-byok:provider"; +import { buildMessagesBody, HttpError, streamMessages } from "./messages.ts"; + +function assert(value: unknown, message = "assertion failed"): asserts value { + if (!value) throw new Error(message); +} +function equal(actual: unknown, expected: unknown): void { + assert( + JSON.stringify(actual) === JSON.stringify(expected), + `expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`, + ); +} +async function rejects(run: () => Promise): Promise { + try { + await run(); + } catch (error) { + return error; + } + throw new Error("expected rejection"); +} +const model: ModelSnapshot = { + id: "claude-test", + displayName: "Claude", + maxOutputTokens: 16000, + privateData: { thinking: "adaptive", efforts: ["low", "medium", "high"] }, +}; +function request(): LlmRequest { + return { + instructions: "Exact system\nDo not modify.", + messages: [{ role: "system", content: [{ type: "text", text: "context A" }] }, { + role: "user", + content: [{ type: "text", text: "hello" }], + }], + tools: [], + reasoning: { enabled: false, effort: null }, + latency: "standard", + maxOutputTokens: null, + cacheKey: "conversation", + }; +} +function event(type: string, fields: Record = {}): string[] { + return [`event: ${type}`, `data: ${JSON.stringify({ type, ...fields })}`, ""]; +} +const start = () => + event("message_start", { + message: { + usage: { + input_tokens: 10, + cache_read_input_tokens: 30, + cache_creation_input_tokens: 20, + output_tokens: 1, + }, + }, + }); +const block = (index: number, content_block: unknown) => + event("content_block_start", { index, content_block }); +const delta = (index: number, delta: unknown) => event("content_block_delta", { index, delta }); +const stop = (index: number) => event("content_block_stop", { index }); +const end = () => [ + ...event("message_delta", { delta: { stop_reason: "end_turn" }, usage: { output_tokens: 7 } }), + ...event("message_stop"), +]; +async function* lines(values: string[]) { + for (const value of values) yield value; +} +function context( + response: NetworkEventStream, + signal = new AbortController().signal, +): PluginContext { + return { + signal, + network: { + fetch: () => { + throw new Error("unexpected fetch"); + }, + stream: (url, init) => { + equal(url, "https://api.anthropic.com/v1/messages"); + equal(init?.headers?.authorization, "Bearer private-token"); + return Promise.resolve(response); + }, + }, + }; +} +async function run(values: string[], events: ModelEvent[] = []): Promise { + await streamMessages(model, request(), { authorization: "Bearer private-token" }, { + emit: (event) => events.push(event), + }, context({ status: 200, headers: {}, lines: lines(values) })); + return events; +} + +Deno.test("messages preserve append-only prefix, system content, and exact tool schemas", () => { + const first = request(); + first.tools = [{ + name: "custom.Tool", + description: "Exact description", + parameters: { type: "object", additionalProperties: false }, + }]; + const before = buildMessagesBody(model, first); + const next = structuredClone(first); + next.messages.push( + { role: "assistant", text: "answer", thinking: "not signed", replayState: null, toolCalls: [] }, + { role: "system", content: [{ type: "text", text: "context B" }] }, + { role: "user", content: [{ type: "text", text: "next" }] }, + ); + const after = buildMessagesBody(model, next); + equal((after.messages as JsonValue[]).slice(0, 2), before.messages); + equal(after.system, before.system); + equal(before.system, first.instructions); + equal(before.messages, [ + { role: "user", content: [{ type: "text", text: "context A" }] }, + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); + equal(before.tools, [{ + name: "custom.Tool", + description: "Exact description", + input_schema: first.tools[0].parameters, + }]); + equal(after.cache_control, { type: "ephemeral" }); + assert(!JSON.stringify(after).includes("not signed")); + next.messages.push({ role: "system", content: [{ type: "text", text: "context A" }] }); + equal((buildMessagesBody(model, next).messages as JsonValue[]).slice(0, 5), after.messages); +}); + +Deno.test("messages serialize user images and rich failed tool results", () => { + const req = request(); + const image = { type: "image" as const, mediaType: "image/png", dataBase64: "YWJj" }; + req.messages = [{ role: "user", content: [image] }, { + role: "tool", + callId: "call-1", + name: "custom.Tool", + content: "ignored", + isError: true, + parts: [{ type: "text", text: "failed" }, image], + }]; + const imageBlock = { + type: "image", + source: { type: "base64", media_type: "image/png", data: "YWJj" }, + }; + equal(buildMessagesBody(model, req).messages, [ + { role: "user", content: [imageBlock] }, + { + role: "user", + content: [{ + type: "tool_result", + tool_use_id: "call-1", + is_error: true, + content: [{ type: "text", text: "failed" }, imageBlock], + }], + }, + ]); +}); + +Deno.test("reasoning uses metadata, output clamp, and supported efforts only", () => { + const req = request(); + equal(buildMessagesBody(model, req).max_tokens, 8192); + req.reasoning = { enabled: true, effort: "high" }; + req.maxOutputTokens = 999999; + const adaptive = buildMessagesBody(model, req); + equal(adaptive.max_tokens, 16000); + equal(adaptive.thinking, { type: "adaptive" }); + equal(adaptive.output_config, { effort: "high" }); + req.reasoning.effort = "unsupported"; + assert(!("output_config" in buildMessagesBody(model, req))); + const enabled = { ...model, privateData: { thinking: "enabled", efforts: [] } }; + req.maxOutputTokens = 1025; + equal(buildMessagesBody(enabled, req).thinking, { type: "enabled", budget_tokens: 1024 }); + req.maxOutputTokens = 100; + assert(!("thinking" in buildMessagesBody(enabled, req))); + assert(!("thinking" in buildMessagesBody({ ...model, privateData: null }, req))); +}); + +Deno.test("stream preserves signed/redacted thinking interleaving, tool indices and usage", async () => { + const events = await run([ + ...start(), + ": comment", + "", + ...event("ping"), + ...block(0, { type: "text", text: "Initial" }), + ...delta(0, { type: "text_delta", text: " text" }), + ...stop(0), + ...block(1, { type: "thinking", thinking: "", signature: "" }), + ...delta(1, { type: "thinking_delta", thinking: "reason" }), + ...delta(1, { type: "signature_delta", signature: "signed" }), + ...stop(1), + ...block(2, { type: "redacted_thinking", data: "opaque" }), + ...stop(2), + ...block(3, { type: "tool_use", id: "call-1", name: "custom.Tool", input: {} }), + ...delta(3, { type: "input_json_delta", partial_json: '{"key":' }), + ...delta(3, { type: "input_json_delta", partial_json: "1}" }), + ...stop(3), + ...block(4, { type: "text", text: "tail" }), + ...stop(4), + ...end(), + ]); + equal(events.filter((e) => e.type.startsWith("tool-call")), [ + { type: "tool-call-start", index: 3, callId: "call-1", name: "custom.Tool" }, + { type: "tool-call-arguments-delta", index: 3, delta: '{"key":' }, + { type: "tool-call-arguments-delta", index: 3, delta: "1}" }, + { type: "tool-call-end", index: 3 }, + ]); + const replay = events.find((e) => e.type === "replay-state"); + assert(replay?.type === "replay-state"); + equal(replay.providerKind, "claude_oauth"); + const blocks = [ + { type: "text", text: "Initial text" }, + { type: "thinking", thinking: "reason", signature: "signed" }, + { type: "redacted_thinking", data: "opaque" }, + { type: "tool_use", id: "call-1", name: "custom.Tool", input: { key: 1 } }, + { type: "text", text: "tail" }, + ]; + equal(replay.value, { blocks }); + const req = request(); + req.messages = [{ + role: "assistant", + text: "canonical merged text", + thinking: "unsigned", + replayState: replay, + toolCalls: [], + }]; + equal(buildMessagesBody(model, req).messages, [{ role: "assistant", content: blocks }]); + equal(events.at(-2), { + type: "usage", + usage: { + inputTokens: 10, + outputTokens: 7, + totalTokens: 67, + cacheReadTokens: 30, + cacheWriteTokens: 20, + reasoningTokens: null, + }, + }); + equal(events.at(-1), { type: "done", reason: "tool-use" }); + equal(events.filter((e) => e.type === "text-start").length, 2); + equal(events.filter((e) => e.type === "text-end").length, 2); + equal(events.filter((e) => e.type === "thinking-start").length, 1); + equal(events.filter((e) => e.type === "thinking-end").length, 1); +}); + +Deno.test("native replay only contributes signed thinking, never foreign or unsigned text", () => { + const req = request(); + req.messages = [{ + role: "assistant", + text: "visible", + thinking: "unsigned", + toolCalls: [], + replayState: { + providerKind: "anthropic", + value: { + blocks: [ + { type: "thinking", thinking: "kept", signature: "sig" }, + { type: "thinking", thinking: "unsigned" }, + { type: "text", text: "discard" }, + { type: "redacted_thinking", data: "opaque" }, + ], + }, + }, + }]; + equal(buildMessagesBody(model, req).messages, [{ + role: "assistant", + content: [ + { type: "thinking", thinking: "kept", signature: "sig" }, + { type: "redacted_thinking", data: "opaque" }, + { type: "text", text: "visible" }, + ], + }]); +}); + +Deno.test("SSE multiline events and empty tool input are handled at block stop", async () => { + const events = await run([ + ...start(), + "event: content_block_start", + 'data: {"type":"content_block_start","index":0,', + 'data: "content_block":{"type":"tool_use","id":"id","name":"tool","input":{}}}', + "", + ...stop(0), + ...end(), + ]); + equal(events.slice(0, 3), [ + { type: "tool-call-start", index: 0, callId: "id", name: "tool" }, + { type: "tool-call-arguments-delta", index: 0, delta: "{}" }, + { type: "tool-call-end", index: 0 }, + ]); +}); + +Deno.test("truncated, malformed and unknown block streams never emit done", async () => { + const cases = [ + [], + [...start()], + [...start(), ...event("message_delta", { delta: { stop_reason: "end_turn" } })], + [...start(), ...block(0, { type: "future_block" }), ...stop(0), ...end()], + [...start(), ...block(0, { type: "text", text: "" }), ...end()], + [...start(), ...delta(0, { type: "text_delta", text: "orphan" }), ...end()], + [...start(), "data: {broken", ""], + [ + ...start(), + ...block(0, { type: "tool_use", id: "id", name: "tool", input: {} }), + ...delta(0, { type: "input_json_delta", partial_json: "{" }), + ...stop(0), + ...end(), + ], + [...start(), ...block(0, { type: "thinking", thinking: "unsigned" }), ...stop(0), ...end()], + [...start(), ...end().slice(0, -1)], + ]; + for (const values of cases) { + const events: ModelEvent[] = []; + await rejects(() => run(values, events)); + assert(!events.some((e) => e.type === "done")); + } +}); + +Deno.test("HTTP and SSE errors retain safe status and Retry-After, not upstream secrets", async () => { + const http = await rejects(() => + streamMessages( + model, + request(), + { authorization: "Bearer private-token" }, + { + emit: () => { + throw new Error("unexpected output"); + }, + }, + context({ + status: 429, + headers: { "retry-after": "30" }, + lines: lines(["secret upstream body"]), + }), + ) + ); + assert(http instanceof HttpError); + equal(http.status, 429); + equal(http.headers["retry-after"], "30"); + assert(!String(http).includes("secret")); + assert(!("body" in http)); + const sse = await rejects(() => + run(event("error", { error: { type: "overloaded_error", message: "secret-token" } })) + ); + assert(sse instanceof HttpError); + equal(sse.status, 529); + equal(sse.errorType, "overloaded_error"); + assert(!String(sse).includes("secret-token")); +}); + +Deno.test("cancellation interrupts pending stream read and preserves abort reason", async () => { + const controller = new AbortController(); + const reason = new Error("test cancellation"); + let reads = 0; + const pending: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: () => { + reads++; + return new Promise>(() => {}); + }, + }), + }; + const promise = streamMessages(model, request(), { authorization: "Bearer private-token" }, { + emit: () => { + throw new Error("unexpected output"); + }, + }, context({ status: 200, headers: {}, lines: pending }, controller.signal)); + await Promise.resolve(); + await Promise.resolve(); + controller.abort(reason); + assert(await rejects(() => promise) === reason); + assert(reads <= 1); +}); + +Deno.test("cancellation before dispatch or during pending headers never emits output", async () => { + for (const alreadyAborted of [true, false]) { + const controller = new AbortController(); + const reason = new Error("cancel pending headers"); + let calls = 0; + if (alreadyAborted) controller.abort(reason); + const ctx: PluginContext = { + signal: controller.signal, + network: { + fetch: () => { + throw new Error("unexpected fetch"); + }, + stream: () => { + calls++; + return new Promise(() => {}); + }, + }, + }; + const promise = streamMessages(model, request(), {}, { + emit: () => { + throw new Error("unexpected event"); + }, + }, ctx); + if (!alreadyAborted) controller.abort(reason); + assert(await rejects(() => promise) === reason); + equal(calls, alreadyAborted ? 0 : 1); + } +}); + +Deno.test("failure after partial output is propagated without retry or success", async () => { + const emitted: ModelEvent[] = []; + let calls = 0; + const ctx = context({ + status: 200, + headers: {}, + lines: lines([ + ...start(), + ...block(0, { type: "text", text: "partial" }), + ...stop(0), + ...event("error", { error: { type: "rate_limit_error", message: "private detail" } }), + ]), + }); + const stream = ctx.network.stream; + ctx.network.stream = (...args) => { + calls++; + return stream(...args); + }; + const error = await rejects(() => + streamMessages(model, request(), { authorization: "Bearer private-token" }, { + emit: (e) => emitted.push(e), + }, ctx) + ); + assert(error instanceof HttpError); + equal(error.status, 429); + equal(calls, 1); + equal(emitted, [{ type: "text-start" }, { type: "text-delta", text: "partial" }, { + type: "text-end", + }]); +}); + +Deno.test("length finish wins over tool presence and absent usage fields stay unknown", async () => { + const events = await run([ + ...event("message_start", { message: { usage: { output_tokens: 0 } } }), + ...block(0, { type: "tool_use", id: "id", name: "tool", input: {} }), + ...stop(0), + ...event("message_delta", { + delta: { stop_reason: "max_tokens" }, + usage: { output_tokens: 5 }, + }), + ...event("message_stop"), + ]); + equal(events.at(-1), { type: "done", reason: "length" }); + equal(events.at(-2), { + type: "usage", + usage: { + inputTokens: null, + outputTokens: 5, + totalTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + reasoningTokens: null, + }, + }); +}); + +Deno.test("foreign replay is ignored and canonical tool calls remain exact", () => { + const req = request(); + req.messages = [{ + role: "assistant", + text: "text", + thinking: "unsigned", + toolCalls: [ + { index: 2, callId: "id", name: "custom.Tool", arguments: { value: 1 } }, + ], + replayState: { + providerKind: "openai_responses", + value: { blocks: [{ type: "text", text: "foreign" }] }, + }, + }]; + equal(buildMessagesBody(model, req).messages, [{ + role: "assistant", + content: [ + { type: "text", text: "text" }, + { type: "tool_use", id: "id", name: "custom.Tool", input: { value: 1 } }, + ], + }]); +}); diff --git a/server/plugins/build-in/claude-auth/models.ts b/server/plugins/build-in/claude-auth/models.ts new file mode 100644 index 000000000..66065eac6 --- /dev/null +++ b/server/plugins/build-in/claude-auth/models.ts @@ -0,0 +1,84 @@ +import type { ModelDefinition, ModelSupport } from "cursor-byok:model"; +import { accountData, apiHeaders, isExpiring, object, text } from "./auth.ts"; + +/** Discover actual account-visible models; never advertise a speculative static catalog. */ +export const claudeModels: ModelSupport = { + async list({ resource }, context): Promise { + if (!resource) throw new Error("Add a Claude account before syncing models."); + const account = accountData(resource); + // ModelSupport.list cannot return a resource patch, so it must not rotate credentials. + if (isExpiring(account)) { + throw new Error("Claude authorization is expiring. Refresh the account, then sync models."); + } + const models: ModelDefinition[] = []; + const seen = new Set(); + const cursors = new Set(); + let after: string | null = null; + for (;;) { + context.signal.throwIfAborted(); + const url = new URL("https://api.anthropic.com/v1/models"); + url.searchParams.set("limit", "100"); + if (after !== null) url.searchParams.set("after_id", after); + const response = await context.network.fetch(url.toString(), { + headers: apiHeaders(account.accessToken), + }); + if (response.status < 200 || response.status >= 300) { + throw new Error( + response.status === 401 + ? "Claude authorization was rejected. Refresh the account, then sync models." + : `Claude model discovery failed (HTTP ${response.status}). Check account access and retry.`, + ); + } + let body: Record | null; + try { + body = object(JSON.parse(response.body)); + } catch { + body = null; + } + if (!body || !Array.isArray(body.data) || typeof body.has_more !== "boolean") { + throw new Error("Claude returned an invalid model list. Retry syncing models."); + } + for (const raw of body.data) { + const value = object(raw); + const id = text(value?.id); + const name = text(value?.display_name); + if (!id || !name) { + throw new Error("Claude returned an incomplete model. Retry syncing models."); + } + if (seen.has(id)) continue; + seen.add(id); + const capabilities = object(value?.capabilities); + const thinking = object(capabilities?.thinking); + const types = object(thinking?.types); + const effort = object(capabilities?.effort); + const supports = (value: unknown) => object(value)?.supported === true; + const maxTokens = value?.max_tokens; + models.push({ + id, + displayName: name, + ...(typeof maxTokens === "number" && Number.isSafeInteger(maxTokens) && maxTokens > 0 + ? { maxOutputTokens: maxTokens } + : {}), + capabilities: { images: supports(capabilities?.image_input) }, + privateData: { + thinking: supports(thinking) + ? supports(types?.adaptive) ? "adaptive" : supports(types?.enabled) ? "enabled" : null + : null, + efforts: supports(effort) + ? ["low", "medium", "high", "xhigh", "max"].filter((level) => + supports(effort?.[level]) + ) + : [], + }, + }); + } + if (!body.has_more) return models; + const cursor = text(body.last_id); + if (!cursor || cursors.has(cursor) || body.data.length === 0) { + throw new Error("Claude model pagination did not advance. Retry syncing models."); + } + cursors.add(cursor); + after = cursor; + } + }, +}; diff --git a/server/plugins/build-in/claude-auth/oauth.ts b/server/plugins/build-in/claude-auth/oauth.ts new file mode 100644 index 000000000..f140a532e --- /dev/null +++ b/server/plugins/build-in/claude-auth/oauth.ts @@ -0,0 +1,97 @@ +import type { OAuth2AuthorizationCodeAddMethod } from "cursor-byok:resource"; +import { + CLIENT_ID, + createAccount, + object, + privateData, + requestTokens, + SCOPES, + text, +} from "./auth.ts"; + +const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize"; +const SESSION_LIFETIME_MS = 10 * 60 * 1000; + +function redirectUri(hostUri: string): string { + const url = new URL(hostUri); + if ( + url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || + url.pathname !== "/callback" || url.search || url.hash || url.username || url.password + ) { + throw new Error( + "Claude sign-in requires a local callback. Restart sign-in from the desktop app.", + ); + } + // Claude's registered loopback redirect uses localhost; the host listens on IPv4 loopback. + url.hostname = "localhost"; + return url.toString(); +} + +export const claudeOAuth: OAuth2AuthorizationCodeAddMethod = { + type: "oauth2.authorization-code", + id: "claude-subscription", + displayName: { + "en-US": "Sign in with Claude", + "ru-RU": "Войти через Claude", + "zh-CN": "使用 Claude 登录", + }, + description: { + "en-US": + "Experimental personal integration. Third-party subscription access violates Anthropic's terms and may be blocked.", + "ru-RU": + "Экспериментальная личная интеграция. Доступ по подписке из сторонних приложений нарушает условия Anthropic и может быть заблокирован.", + "zh-CN": "实验性个人集成。第三方订阅访问违反 Anthropic 条款,可能被封禁。", + }, + callback: { path: "/callback" }, + begin(input, context) { + context.signal.throwIfAborted(); + const redirect = redirectUri(input.redirectUri); + if (!text(input.state) || !text(input.codeChallenge)) { + throw new Error("Claude sign-in protection is missing. Restart sign-in."); + } + const expiresAtMs = Date.now() + SESSION_LIFETIME_MS; + const params = new URLSearchParams({ + code: "true", + client_id: CLIENT_ID, + response_type: "code", + redirect_uri: redirect, + scope: SCOPES.join(" "), + state: input.state, + code_challenge: input.codeChallenge, + code_challenge_method: "S256", + }); + return Promise.resolve({ + session: { state: input.state, redirectUri: redirect, expiresAtMs }, + authorizationUrl: `${AUTHORIZATION_URL}?${params}`, + expiresAtMs, + }); + }, + async complete(sessionValue, input, context) { + context.signal.throwIfAborted(); + const session = object(sessionValue); + const state = text(session?.state); + const redirect = text(session?.redirectUri); + const expiresAtMs = session?.expiresAtMs; + if ( + !state || !redirect || typeof expiresAtMs !== "number" || + !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now() || + redirect !== redirectUri(input.redirectUri) || !text(input.code) || !text(input.codeVerifier) + ) { + throw new Error("Claude sign-in expired or changed. Restart sign-in."); + } + // The host validates callback state before invoking complete; Claude also requires it here. + const body = await requestTokens({ + grant_type: "authorization_code", + code: input.code, + state, + redirect_uri: redirect, + code_verifier: input.codeVerifier, + }, context); + const data = await createAccount(body, context); + return [{ + key: `claude:${data.organizationId}:${data.accountId}`, + privateData: privateData(data), + state: { status: "ready" }, + }]; + }, +}; diff --git a/server/plugins/build-in/claude-auth/plugin.json b/server/plugins/build-in/claude-auth/plugin.json new file mode 100644 index 000000000..acad78331 --- /dev/null +++ b/server/plugins/build-in/claude-auth/plugin.json @@ -0,0 +1,12 @@ +{ + "apiVersion": 1, + "id": "dev.cursorbyok.examples.claude-auth", + "name": "Claude OAuth (experimental)", + "version": "0.1.0", + "minAppVersion": "0.1.0", + "icon": "assets/plugin.svg", + "entry": "main.ts", + "permissions": { + "network": ["claude.ai", "platform.claude.com", "api.anthropic.com"] + } +} diff --git a/server/plugins/build-in/claude-auth/provider.ts b/server/plugins/build-in/claude-auth/provider.ts new file mode 100644 index 000000000..1eb303671 --- /dev/null +++ b/server/plugins/build-in/claude-auth/provider.ts @@ -0,0 +1,131 @@ +import type { ProviderResult, ProviderSupport } from "cursor-byok:provider"; +import type { ResourcePatch, ResourceState } from "cursor-byok:resource"; +import { + type AccountData, + accountData, + apiHeaders, + isExpiring, + OAuthError, + privateData, + refreshTokens, + RESOURCE_TYPE, +} from "./auth.ts"; +import { HttpError, streamMessages } from "./messages.ts"; +import { claudeModels } from "./models.ts"; + +export function retryAtMs(headers: Record, now = Date.now()): number { + const value = Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1]; + if (value) { + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return now + Math.max(1, seconds) * 1000; + const date = Date.parse(value); + if (Number.isFinite(date)) return Math.max(now + 1000, date); + } + return now + 60_000; +} + +export const claudeProvider: ProviderSupport = { + id: "claude", + displayName: "Claude (experimental OAuth)", + description: { + "en-US": "Unofficial personal subscription access to the Anthropic Messages API.", + "ru-RU": "Неофициальный личный доступ по подписке к Anthropic Messages API.", + "zh-CN": "通过订阅个人访问 Anthropic Messages API 的非官方集成。", + }, + providerType: "anthropic", + resourceType: RESOURCE_TYPE, + models: claudeModels, + async invoke(input, output, context): Promise { + if (!input.resource) { + return { + status: "request-error", + message: "Add a Claude account before selecting this model.", + }; + } + let data: AccountData; + try { + data = accountData(input.resource); + } catch { + const message = "Claude account credentials are incomplete. Sign in again."; + return { + status: "resource-error", + message, + patch: { state: { status: "invalid", message } }, + }; + } + let patch: ResourcePatch | undefined; + let refreshed = false; + let emitted = false; + const sink = { + emit: (event: Parameters[0]) => { + emitted = true; + output.emit(event); + }, + }; + const refresh = async () => { + data = await refreshTokens(data, context); + patch = { privateData: privateData(data), state: { status: "ready" } }; + refreshed = true; + }; + const failResource = (message: string, state: ResourceState): ProviderResult => ({ + status: "resource-error", + message, + patch: { ...patch, state }, + }); + try { + context.signal.throwIfAborted(); + if (isExpiring(data)) await refresh(); + try { + await streamMessages( + input.model, + input.request, + apiHeaders(data.accessToken), + sink, + context, + ); + } catch (error) { + // A single retry is safe only before any streamed event and after an HTTP 401. + if ( + !(error instanceof HttpError) || error.errorType !== undefined || error.status !== 401 || + emitted || refreshed + ) throw error; + await refresh(); + await streamMessages( + input.model, + input.request, + apiHeaders(data.accessToken), + sink, + context, + ); + } + return { status: "completed", ...(patch ? { patch } : {}) }; + } catch (error) { + if (error instanceof OAuthError && error.invalidCredentials) { + return failResource(error.message, { status: "invalid", message: error.message }); + } + if (error instanceof HttpError) { + if (error.status === 401 || error.status === 403) { + const message = error.status === 401 + ? "Claude authorization was rejected. Sign in again." + : "Claude denied API access. Check your subscription; third-party OAuth may be blocked."; + return failResource(message, { status: "invalid", message }); + } + if (error.status === 429) { + const message = "Claude usage is temporarily limited. Wait before trying again."; + return failResource(message, { + status: "cooling", + retryAtMs: retryAtMs(error.headers), + message, + }); + } + } + const message = context.signal.aborted + ? "Claude request was cancelled." + : error instanceof OAuthError || error instanceof HttpError + ? error.message + : "Claude request failed or its response was incomplete. Check the connection and retry."; + // Rotated credentials must survive upstream errors as well as successful requests. + return { status: "request-error", message, ...(patch ? { patch } : {}) }; + } + }, +}; diff --git a/server/plugins/build-in/claude-auth/resources.ts b/server/plugins/build-in/claude-auth/resources.ts new file mode 100644 index 000000000..206a1ff7f --- /dev/null +++ b/server/plugins/build-in/claude-auth/resources.ts @@ -0,0 +1,32 @@ +import type { ResourceSupport, ResourceView } from "cursor-byok:resource"; +import { accountData, OAuthError, privateData, refreshTokens, RESOURCE_TYPE } from "./auth.ts"; +import { claudeOAuth } from "./oauth.ts"; + +export const claudeAccounts: ResourceSupport = { + type: RESOURCE_TYPE, + displayName: { "en-US": "Claude accounts", "ru-RU": "Аккаунты Claude", "zh-CN": "Claude 账号" }, + add: [claudeOAuth], + present(resource): ResourceView { + const data = accountData(resource); + return { + displayName: data.displayName, + description: { + "en-US": "Experimental subscription access. Anthropic may restrict this connection.", + "ru-RU": "Экспериментальный доступ по подписке. Anthropic может ограничить подключение.", + "zh-CN": "实验性订阅访问。Anthropic 可能限制此连接。", + }, + }; + }, + async refresh(resource, context) { + const data = accountData(resource); + try { + const refreshed = await refreshTokens(data, context); + return { privateData: privateData(refreshed), state: { status: "ready" } }; + } catch (error) { + if (error instanceof OAuthError && error.invalidCredentials) { + return { state: { status: "invalid", message: error.message } }; + } + throw error; + } + }, +}; diff --git a/server/src/plugin/builtin.rs b/server/src/plugin/builtin.rs index f2ca588e1..bfda21969 100644 --- a/server/src/plugin/builtin.rs +++ b/server/src/plugin/builtin.rs @@ -168,10 +168,50 @@ const ANTIGRAVITY_AUTH: &[(&str, &str)] = &[ ), ]; +const CLAUDE_AUTH: &[(&str, &str)] = &[ + ( + "plugin.json", + include_str!("../../plugins/build-in/claude-auth/plugin.json"), + ), + ( + "main.ts", + include_str!("../../plugins/build-in/claude-auth/main.ts"), + ), + ( + "auth.ts", + include_str!("../../plugins/build-in/claude-auth/auth.ts"), + ), + ( + "oauth.ts", + include_str!("../../plugins/build-in/claude-auth/oauth.ts"), + ), + ( + "resources.ts", + include_str!("../../plugins/build-in/claude-auth/resources.ts"), + ), + ( + "models.ts", + include_str!("../../plugins/build-in/claude-auth/models.ts"), + ), + ( + "messages.ts", + include_str!("../../plugins/build-in/claude-auth/messages.ts"), + ), + ( + "provider.ts", + include_str!("../../plugins/build-in/claude-auth/provider.ts"), + ), + ( + "assets/plugin.svg", + include_str!("../../plugins/build-in/claude-auth/assets/plugin.svg"), + ), +]; + const PLUGINS: &[(&str, &[(&str, &str)])] = &[ ("codex-auth", CODEX_AUTH), ("grok-auth", GROK_AUTH), ("antigravity-auth", ANTIGRAVITY_AUTH), + ("claude-auth", CLAUDE_AUTH), ]; /// 把内置插件预装到 installed 目录。manifest 的 version 是缓存键: @@ -276,6 +316,12 @@ mod tests { .path() .join("antigravity-auth/assets/antigravity.svg") .is_file()); + for (relative, expected) in CLAUDE_AUTH { + assert_eq!( + std::fs::read_to_string(root.path().join("claude-auth").join(relative)).unwrap(), + *expected + ); + } // 版本一致:本地改动与额外文件保持原样,不发生任何写盘。 std::fs::write(plugin.join("main.ts"), "edited").unwrap(); diff --git a/server/src/plugin/worker.rs b/server/src/plugin/worker.rs index b56c867b6..cf1d536bd 100644 --- a/server/src/plugin/worker.rs +++ b/server/src/plugin/worker.rs @@ -468,6 +468,7 @@ impl HostContext { &self, request_id: &str, params: &serde_json::Value, + record_stream: bool, ) -> Result<( reqwest::RequestBuilder, CancellationToken, @@ -519,7 +520,11 @@ impl HostContext { .as_ref() .map(|state| state.cancellation.clone()) .unwrap_or_default(); - let recorder = invocation.and_then(|state| state.claim_recorder()); + // Only inference streams claim the model recorder. Auxiliary fetches may + // contain OAuth refresh tokens in both their request and response bodies. + let recorder = invocation + .filter(|_| record_stream) + .and_then(|state| state.claim_recorder()); if let Some(recorder) = &recorder { let (headers, body) = recorded_network_request(params)?; recorder.request(headers, &body).await?; @@ -532,7 +537,7 @@ impl HostContext { request_id: &str, params: serde_json::Value, ) -> Result { - let (request, cancellation, recorder) = self.request(request_id, ¶ms).await?; + let (request, cancellation, recorder) = self.request(request_id, ¶ms, false).await?; let request = request.timeout(Duration::from_secs(60)); let response = tokio::select! { _ = cancellation.cancelled() => return Err(Error::Cancelled), @@ -574,7 +579,7 @@ impl HostContext { request_id: &str, params: serde_json::Value, ) -> Result { - let (request, cancellation, recorder) = self.request(request_id, ¶ms).await?; + let (request, cancellation, recorder) = self.request(request_id, ¶ms, true).await?; let response = tokio::select! { _ = cancellation.cancelled() => return Err(Error::Cancelled), response = request.send() => response?, @@ -813,8 +818,8 @@ mod tests { let (_directory, store, recorder) = recorder(true, "detailed-plugin").await; let host = host_with_recorder(store.clone(), recorder.clone()).await; let params = network_params(); - let (_, _, first_recorder) = host.request("invocation", ¶ms).await.unwrap(); - let (_, _, second_recorder) = host.request("invocation", ¶ms).await.unwrap(); + let (_, _, first_recorder) = host.request("invocation", ¶ms, true).await.unwrap(); + let (_, _, second_recorder) = host.request("invocation", ¶ms, true).await.unwrap(); let (_, body) = recorded_network_request(¶ms).unwrap(); assert!(first_recorder.is_some()); @@ -857,6 +862,31 @@ mod tests { assert!(summary.detailed); } + #[tokio::test] + async fn oauth_fetch_does_not_record_tokens_or_claim_the_model_recorder() { + let (_directory, store, recorder) = recorder(true, "oauth-plugin").await; + let host = host_with_recorder(store.clone(), recorder).await; + let oauth = serde_json::json!({ + "url": "https://example.com/oauth/token", + "method": "POST", + "headers": { "content-type": "application/json" }, + "body": "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"secret\"}" + }); + let (_, _, oauth_recorder) = host.request("invocation", &oauth, false).await.unwrap(); + assert!(oauth_recorder.is_none()); + assert!(store.llm_call_request("oauth-plugin").await.unwrap().is_none()); + assert!(store.llm_call_chunks("oauth-plugin").await.unwrap().is_empty()); + + let (_, _, model_recorder) = host + .request("invocation", &network_params(), true) + .await + .unwrap(); + assert!(model_recorder.is_some()); + let recorded = store.llm_call_request("oauth-plugin").await.unwrap().unwrap(); + assert_eq!(recorded.body, serde_json::json!({ "model": "test", "stream": true })); + assert!(!recorded.body.to_string().contains("secret")); + } + #[tokio::test] async fn standard_plugin_network_recording_keeps_metrics_without_payloads() { let (_directory, store, recorder) = recorder(false, "standard-plugin").await; @@ -866,7 +896,7 @@ mod tests { let request_bytes = serde_json::to_string(&body).unwrap().len() as i64; let response = b"data: [DONE]\n\n"; - let (_, _, observed) = host.request("invocation", ¶ms).await.unwrap(); + let (_, _, observed) = host.request("invocation", ¶ms, true).await.unwrap(); assert!(observed.is_some()); recorder.response_headers(204).await.unwrap(); recorder.response_chunk(response).await.unwrap(); From 64b7fb18d7db36d72d5214ada8eb41c3c1957cfa Mon Sep 17 00:00:00 2001 From: sys Date: Wed, 30 Sep 2026 22:02:40 +0300 Subject: [PATCH 2/3] test(plugins): verify Claude OAuth lifecycle and add CI coverage --- .github/workflows/ci.yml | 25 +++ server/plugins/build-in/claude-auth/README.md | 77 +++++-- server/plugins/build-in/claude-auth/deno.json | 1 + .../build-in/claude-auth/host_smoke.ts | 210 ++++++++++++++++++ server/src/plugin/worker.rs | 23 +- 5 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 server/plugins/build-in/claude-auth/host_smoke.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e73f08af..5ec4db3b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,31 @@ jobs: - name: Run tests run: cargo test --workspace --all-targets + claude-plugin: + name: Claude plugin (Deno) + runs-on: ubuntu-22.04 + defaults: + run: + working-directory: server/plugins/build-in/claude-auth + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + # Match the runtime version in server/src/plugin/asset.rs. + deno-version: v2.9.6 + + - name: Check types, lint, and formatting + run: | + deno task check + deno task lint + deno task fmt + + - name: Run offline plugin and sandboxed worker tests + run: | + deno task test + deno task test:host + frontend: name: Frontend runs-on: ubuntu-22.04 diff --git a/server/plugins/build-in/claude-auth/README.md b/server/plugins/build-in/claude-auth/README.md index 8c8b6e127..08845404c 100644 --- a/server/plugins/build-in/claude-auth/README.md +++ b/server/plugins/build-in/claude-auth/README.md @@ -7,9 +7,11 @@ prohibits offering third-party Claude.ai login and routing requests through subs credentials. Access may be denied or restricted without notice. An existing subscription does not guarantee API access through this plugin. -**Status:** local, mocked protocol tests pass. Browser consent, token exchange, profile/model -access, and inference have not been tested against a real account. This is an experimental -implementation, not a verified working subscription connection. +**Status:** automated Linux checks and a live account smoke test passed on 2026-09-30. The live +check covered browser consent, code exchange, profile lookup, token refresh, discovery of 13 models, +and one streamed text response from `claude-haiku-4-5-20251001`. This remains an experimental, +unofficial integration; other models, accounts, and the full desktop sign-in UI were not +live-tested. ## Structure @@ -27,6 +29,7 @@ server/ │ ├── assets/plugin.svg Neutral plugin icon, not Anthropic branding │ ├── claude_test.ts Mocked OAuth, resource, discovery, and provider tests │ ├── messages_test.ts Mocked Messages protocol and history-prefix tests +│ ├── host_smoke.ts Full lifecycle through a real sandboxed Deno worker │ └── deno.json Local SDK imports and development tasks └── src/plugin/ Existing plugin host ├── builtin.rs Embeds this plugin in release builds @@ -63,9 +66,8 @@ Model invocation - Authorization/token constants match inspected Claude Code and current public client implementations. -- The plugin requests only `user:profile user:inference`; acceptance of this reduced scope set has - not been verified with a live account. It does not request API-key creation, file-upload, or MCP - rights. +- The plugin requests only `user:profile user:inference`. This requested scope set passed the live + smoke test. It does not request API-key creation, file-upload, or MCP rights. - `localhost` is used in both authorization and exchange. The existing host still binds IPv4 loopback. The browser must run on the same machine as the server, with working localhost resolution. @@ -93,10 +95,10 @@ The plugin sends Bearer authorization, `anthropic-version: 2023-06-01`, and not impersonate the official Claude Code client**, inject an official-client system identity, or forge a Claude CLI version. -Current third-party clients sometimes add these identity transformations to avoid subscription API -rejections. Their necessity is not officially documented, but this is a significant compatibility -risk: authorization can succeed while inference is rejected. This implementation reports the refusal -instead of claiming that OAuth login guarantees usable inference. +A live Haiku request succeeded without these transformations on 2026-09-30. That result does not +establish compatibility with every model or account. Anthropic may still reject third-party +subscription requests, so the plugin reports upstream refusals rather than treating OAuth login as a +guarantee of inference access. ## Install and try @@ -105,8 +107,10 @@ host recording fix is necessary: the upstream host could record a refresh-token the first model request when detailed logging was enabled. The plugin is bundled automatically through `server/src/plugin/builtin.rs`. Debug builds discover it -from `server/plugins/build-in/claude-auth`. Build instructions are in the repository's -[contributing guide](../../../../CONTRIBUTING_EN.md). +from `server/plugins/build-in/claude-auth`. Linux system dependencies and verification commands are +listed in the repository's [CI workflow](../../../../.github/workflows/ci.yml). With Rust, Node.js +22, and those dependencies installed, run `npm ci` followed by `npm run tauri:build -- --no-bundle` +from `apps/desktop` to build without publishing a release. After starting that build: @@ -116,38 +120,63 @@ After starting that build: 4. Sync the model catalog and enable a returned model. 5. Run a short connectivity test before using a real conversation. -No account login, token import, installation into the running app, or live model call is performed -by this change. A source checkout does not isolate application data: the host uses -`~/.cursor-byok-v3` by default. Use a separate OS account/environment for an isolated runtime -profile. +Building or installing this change does not automatically sign in or import credentials. A source +checkout does not isolate application data: the host uses `~/.cursor-byok-v3` by default. Use a +separate OS account/environment for an isolated runtime profile. Deleting an account removes the local resource through the host; there is no plugin-specific remote revocation hook. Revoke the authorization through Anthropic's account controls when needed. ## Verification -From this directory with Deno 2 installed: +From this directory with Deno 2.9.6 installed (the version used by the host): ```sh deno task check deno task lint deno task fmt deno task test +deno task test:host ``` -Tests use mocked networking and need no tokens or network permissions. They cover PKCE/state -forwarding, callback consistency, expiry, identity lookup, refresh-token rotation/coalescing, safe -errors, single-retry behavior, account cooling, pagination, append-only history, images, tool calls, -ordered signed/redacted thinking, usage merging, cancellation, and truncated streams. +The 32 unit tests use mocked networking and need no tokens or network permissions. The separate +worker integration test starts the actual SDK worker with the host's sandbox flags and drives OAuth +completion, profile lookup, model discovery, automatic token refresh, streaming events, and +credential patches through its JSON protocol. Its parent test process needs subprocess and read +permissions; the child has only plugin/SDK read access and no direct network access. All upstream +responses in this test are simulated, so it does not establish live Claude compatibility. -Host regression tests, from `server/` with the Rust toolchain and build prerequisites installed: +These checks also run in the `Claude plugin (Deno)` CI job. Tests cover PKCE/state forwarding, +callback consistency, expiry, identity lookup, refresh-token rotation/coalescing, safe errors, +single-retry behavior, account cooling, pagination, append-only history, images, tool calls, ordered +signed/redacted thinking, usage merging, cancellation, and truncated streams. + +Workspace verification, from the repository root with the Rust toolchain and build prerequisites +installed: ```sh -cargo test --lib plugin::builtin::tests -cargo test --lib plugin::worker::tests cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets -- -D warnings +cargo test --locked --workspace --all-targets +cargo build --locked --workspace ``` +On Linux with Rust 1.98.1, all 306 workspace tests, strict Clippy, formatting, and the workspace +debug build passed, including the OAuth recording regression test. The desktop TypeScript checks and +Vite production build also passed with cached dependencies. A fresh `npm ci` was blocked by a +registry download timeout. macOS/Windows checks and graphical desktop interaction were not run +locally. + +### Live smoke test + +On 2026-09-30, a temporary harness called the real plugin modules with a loopback callback and +explicit browser consent. Code exchange, profile lookup, immediate refresh-token rotation, discovery +of 13 models, and a short streamed response from `claude-haiku-4-5-20251001` all succeeded. +Credentials were kept only in process memory and were not written to logs, Git, or the running +application's profile. This verified the plugin against the upstream service, not the full graphical +desktop workflow or durable account recovery after restart. Tool calling, thinking replay, rate +limits, and error paths are covered by mocked tests rather than this single live inference request. + ## Protocol references - [Official policy](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) diff --git a/server/plugins/build-in/claude-auth/deno.json b/server/plugins/build-in/claude-auth/deno.json index d06f6fa7f..40fc69e6e 100644 --- a/server/plugins/build-in/claude-auth/deno.json +++ b/server/plugins/build-in/claude-auth/deno.json @@ -8,6 +8,7 @@ "tasks": { "check": "deno check main.ts", "test": "deno test --no-remote", + "test:host": "deno test --no-remote --allow-run --allow-read host_smoke.ts", "lint": "deno lint", "fmt": "deno fmt --check" }, diff --git a/server/plugins/build-in/claude-auth/host_smoke.ts b/server/plugins/build-in/claude-auth/host_smoke.ts new file mode 100644 index 000000000..dfabb780b --- /dev/null +++ b/server/plugins/build-in/claude-auth/host_smoke.ts @@ -0,0 +1,210 @@ +import type { JsonValue } from "cursor-byok:plugin"; + +function assert(condition: unknown, message = "assertion failed"): asserts condition { + if (!condition) throw new Error(message); +} +function equal(actual: unknown, expected: unknown): void { + assert(JSON.stringify(actual) === JSON.stringify(expected), "unexpected worker result"); +} + +type Packet = Record; +async function* packets(stream: ReadableStream): AsyncGenerator { + let buffered = ""; + for await (const chunk of stream.pipeThrough(new TextDecoderStream())) { + buffered += chunk; + let newline; + while ((newline = buffered.indexOf("\n")) >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) yield JSON.parse(line); + } + } + assert(!buffered, "worker ended with an incomplete protocol packet"); +} + +Deno.test("actual sandboxed worker completes OAuth, discovery, refresh, and streaming lifecycle", async () => { + const root = Deno.cwd(); + const sdk = `${root}/../../../src/plugin/sdk`; + const child = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--quiet", + "--no-config", + "--no-lock", + "--no-npm", + "--no-remote", + "--no-prompt", + `--allow-read=${root},${sdk}`, + `--import-map=${sdk}/import-map.json`, + `${sdk}/worker.ts`, + new URL("./main.ts", import.meta.url).href, + ], + stdin: "piped", + stdout: "piped", + stderr: "piped", + }).spawn(); + const timeout = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { /* Already exited. */ } + }, 15_000); + const stderr = new Response(child.stderr).text(); + const input = child.stdin.getWriter(); + const output = packets(child.stdout); + let sequence = 0; + let refreshes = 0; + let streamClosed = false; + const send = (packet: Packet) => + input.write(new TextEncoder().encode(JSON.stringify(packet) + "\n")); + const upstreamEvents = [ + { type: "message_start", message: { usage: { input_tokens: 4, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + const http = (body: JsonValue) => ({ status: 200, headers: {}, body: JSON.stringify(body) }); + + async function hostCall(packet: Packet): Promise { + const params = packet.params as Record; + let result: JsonValue; + if (packet.method === "network.fetch") { + if (params.url === "https://platform.claude.com/v1/oauth/token") { + const body = JSON.parse(params.body as string); + if (body.grant_type === "authorization_code") { + equal(body.state, "host-state"); + equal(body.redirect_uri, "http://localhost:43210/callback"); + equal(body.code_verifier, "host-verifier"); + } else { + equal(body.grant_type, "refresh_token"); + equal(body.refresh_token, "initial-refresh"); + refreshes++; + } + result = http({ + access_token: refreshes ? "rotated-access" : "initial-access", + refresh_token: refreshes ? "rotated-refresh" : "initial-refresh", + expires_in: 3600, + scope: "user:profile user:inference", + }); + } else if (params.url === "https://api.anthropic.com/api/oauth/profile") { + result = http({ + account: { uuid: "account", email: "test@example.com" }, + organization: { uuid: "org" }, + }); + } else if (String(params.url).startsWith("https://api.anthropic.com/v1/models?")) { + result = http({ + data: [{ id: "claude-test", display_name: "Test model" }], + has_more: false, + }); + } else throw new Error("unexpected auxiliary endpoint"); + } else if (packet.method === "network.stream.open") { + equal(params.url, "https://api.anthropic.com/v1/messages"); + equal((params.headers as Packet).authorization, "Bearer rotated-access"); + equal(JSON.parse(params.body as string).model, "claude-test"); + result = { streamId: "stream-1", status: 200, headers: {} }; + } else if (packet.method === "network.stream.read") { + equal(params.streamId, "stream-1"); + result = { + lines: upstreamEvents.flatMap((event) => [`data: ${JSON.stringify(event)}`, ""]), + done: true, + }; + } else if (packet.method === "network.stream.close") { + streamClosed = true; + result = null; + } else throw new Error("unexpected host method"); + await send({ type: "host_result", id: packet.id, result }); + } + + async function invoke( + method: string, + params: Packet, + ): Promise<{ result: JsonValue; events: JsonValue[] }> { + const id = `test-${++sequence}`; + await send({ type: "request", id, method, params }); + const events: JsonValue[] = []; + for (;;) { + const item = await output.next(); + assert(!item.done, "worker exited before its terminal result"); + const packet = item.value; + if (packet.type === "host_call") { + await hostCall(packet); + } else { + equal(packet.id, id); + if (packet.type === "event") events.push(packet.event); + else { + equal(packet.type, "result"); + assert(!packet.error, String(packet.error)); + return { result: packet.result, events }; + } + } + } + } + + try { + const common = { resourceType: "claude-account", methodId: "claude-subscription" }; + const begin = (await invoke("oauth.begin", { + ...common, + authorization: { + redirectUri: "http://127.0.0.1:43210/callback", + state: "host-state", + codeChallenge: "host-challenge", + }, + })).result as Packet; + assert(String(begin.authorizationUrl).startsWith("https://claude.ai/oauth/authorize?")); + const drafts = (await invoke("oauth.complete", { + ...common, + session: begin.session, + authorization: { + redirectUri: "http://127.0.0.1:43210/callback", + code: "test-code", + codeVerifier: "host-verifier", + }, + })).result as Packet[]; + const account: Packet = { id: "resource-1", type: "claude-account", ...drafts[0] }; + equal(account.key, "claude:org:account"); + const views = + (await invoke("resource.present", { resourceType: "claude-account", resources: [account] })) + .result; + assert(!JSON.stringify(views).includes("initial-access")); + assert(!JSON.stringify(views).includes("initial-refresh")); + const models = (await invoke("models.list", { providerId: "claude", resource: account })) + .result as Packet[]; + equal(models[0].id, "claude-test"); + // Expiry triggers refresh inside provider.invoke, not a separate manually refreshed lifecycle. + (account.privateData as Packet).expiresAtMs = Date.now() - 1; + const call = await invoke("provider.invoke", { + providerId: "claude", + resource: account, + model: models[0], + request: { + instructions: "Help with code.", + messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + tools: [], + reasoning: { enabled: false, effort: null }, + latency: "standard", + maxOutputTokens: 1024, + cacheKey: "conversation", + }, + }); + equal((call.result as Packet).status, "completed"); + const patch = (call.result as Packet).patch as Packet; + equal((patch.privateData as Packet).refreshToken, "rotated-refresh"); + equal(patch.state, { status: "ready" }); + equal(refreshes, 1); + assert(call.events.some((event) => (event as Packet).type === "text-delta")); + equal(call.events.at(-1), { type: "done", reason: "stop" }); + // The close host call may trail the result; a subsequent request drains it as in the real host. + await invoke("resource.present", { + resourceType: "claude-account", + resources: [{ ...account, ...patch }], + }); + assert(streamClosed, "worker did not release its host stream"); + } finally { + await input.close(); + const status = await child.status; + clearTimeout(timeout); + await output.return(undefined); + assert(status.success, `worker failed: ${await stderr}`); + } +}); diff --git a/server/src/plugin/worker.rs b/server/src/plugin/worker.rs index cf1d536bd..d87c2ade1 100644 --- a/server/src/plugin/worker.rs +++ b/server/src/plugin/worker.rs @@ -874,16 +874,31 @@ mod tests { }); let (_, _, oauth_recorder) = host.request("invocation", &oauth, false).await.unwrap(); assert!(oauth_recorder.is_none()); - assert!(store.llm_call_request("oauth-plugin").await.unwrap().is_none()); - assert!(store.llm_call_chunks("oauth-plugin").await.unwrap().is_empty()); + assert!(store + .llm_call_request("oauth-plugin") + .await + .unwrap() + .is_none()); + assert!(store + .llm_call_chunks("oauth-plugin") + .await + .unwrap() + .is_empty()); let (_, _, model_recorder) = host .request("invocation", &network_params(), true) .await .unwrap(); assert!(model_recorder.is_some()); - let recorded = store.llm_call_request("oauth-plugin").await.unwrap().unwrap(); - assert_eq!(recorded.body, serde_json::json!({ "model": "test", "stream": true })); + let recorded = store + .llm_call_request("oauth-plugin") + .await + .unwrap() + .unwrap(); + assert_eq!( + recorded.body, + serde_json::json!({ "model": "test", "stream": true }) + ); assert!(!recorded.body.to_string().contains("secret")); } From 5fff04775a174ba4ba3a1bffe130335fb6883fb0 Mon Sep 17 00:00:00 2001 From: sys Date: Wed, 30 Sep 2026 22:08:00 +0300 Subject: [PATCH 3/3] feat(plugins): present Claude OAuth as a complete connection Co-authored-by: Cursor --- server/plugins/build-in/claude-auth/README.md | 20 +++++++------------ server/plugins/build-in/claude-auth/oauth.ts | 8 +++----- .../plugins/build-in/claude-auth/plugin.json | 4 ++-- .../plugins/build-in/claude-auth/provider.ts | 8 ++++---- .../plugins/build-in/claude-auth/resources.ts | 6 +++--- 5 files changed, 19 insertions(+), 27 deletions(-) diff --git a/server/plugins/build-in/claude-auth/README.md b/server/plugins/build-in/claude-auth/README.md index 08845404c..954397bdd 100644 --- a/server/plugins/build-in/claude-auth/README.md +++ b/server/plugins/build-in/claude-auth/README.md @@ -1,17 +1,11 @@ -# Claude OAuth (experimental) +# Claude OAuth -Personal, unofficial subscription integration for Cursor BYOK. This is **not an Anthropic-supported -login method**. Anthropic's -[authentication policy](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use) -prohibits offering third-party Claude.ai login and routing requests through subscription -credentials. Access may be denied or restricted without notice. An existing subscription does not -guarantee API access through this plugin. +Complete Claude subscription connection for Cursor BYOK. Sign in with a Claude account, sync the +models available to that subscription, and use them through the Anthropic Messages API. -**Status:** automated Linux checks and a live account smoke test passed on 2026-09-30. The live -check covered browser consent, code exchange, profile lookup, token refresh, discovery of 13 models, -and one streamed text response from `claude-haiku-4-5-20251001`. This remains an experimental, -unofficial integration; other models, accounts, and the full desktop sign-in UI were not -live-tested. +**Status:** the plugin is a full connection. Automated Linux checks and a live account smoke test +passed on 2026-09-30: browser consent, code exchange, profile lookup, token refresh, discovery of 13 +models, and a streamed response from `claude-haiku-4-5-20251001`. ## Structure @@ -115,7 +109,7 @@ from `apps/desktop` to build without publishing a release. After starting that build: 1. Initialize the plugin runtime if the application asks. -2. Open plugin management and select **Claude OAuth (experimental)**. +2. Open plugin management and select **Claude OAuth**. 3. Choose **Sign in with Claude** and complete consent in the local browser. 4. Sync the model catalog and enable a returned model. 5. Run a short connectivity test before using a real conversation. diff --git a/server/plugins/build-in/claude-auth/oauth.ts b/server/plugins/build-in/claude-auth/oauth.ts index f140a532e..762fae159 100644 --- a/server/plugins/build-in/claude-auth/oauth.ts +++ b/server/plugins/build-in/claude-auth/oauth.ts @@ -36,11 +36,9 @@ export const claudeOAuth: OAuth2AuthorizationCodeAddMethod = { "zh-CN": "使用 Claude 登录", }, description: { - "en-US": - "Experimental personal integration. Third-party subscription access violates Anthropic's terms and may be blocked.", - "ru-RU": - "Экспериментальная личная интеграция. Доступ по подписке из сторонних приложений нарушает условия Anthropic и может быть заблокирован.", - "zh-CN": "实验性个人集成。第三方订阅访问违反 Anthropic 条款,可能被封禁。", + "en-US": "Sign in and connect your full Claude subscription.", + "ru-RU": "Войдите и подключите полноценную подписку Claude.", + "zh-CN": "登录并连接完整的 Claude 订阅。", }, callback: { path: "/callback" }, begin(input, context) { diff --git a/server/plugins/build-in/claude-auth/plugin.json b/server/plugins/build-in/claude-auth/plugin.json index acad78331..9d3be1e40 100644 --- a/server/plugins/build-in/claude-auth/plugin.json +++ b/server/plugins/build-in/claude-auth/plugin.json @@ -1,8 +1,8 @@ { "apiVersion": 1, "id": "dev.cursorbyok.examples.claude-auth", - "name": "Claude OAuth (experimental)", - "version": "0.1.0", + "name": "Claude OAuth", + "version": "0.1.1", "minAppVersion": "0.1.0", "icon": "assets/plugin.svg", "entry": "main.ts", diff --git a/server/plugins/build-in/claude-auth/provider.ts b/server/plugins/build-in/claude-auth/provider.ts index 1eb303671..0327fd025 100644 --- a/server/plugins/build-in/claude-auth/provider.ts +++ b/server/plugins/build-in/claude-auth/provider.ts @@ -26,11 +26,11 @@ export function retryAtMs(headers: Record, now = Date.now()): nu export const claudeProvider: ProviderSupport = { id: "claude", - displayName: "Claude (experimental OAuth)", + displayName: "Claude", description: { - "en-US": "Unofficial personal subscription access to the Anthropic Messages API.", - "ru-RU": "Неофициальный личный доступ по подписке к Anthropic Messages API.", - "zh-CN": "通过订阅个人访问 Anthropic Messages API 的非官方集成。", + "en-US": "Full Claude subscription access through the Anthropic Messages API.", + "ru-RU": "Полноценный доступ к Claude по подписке через Anthropic Messages API.", + "zh-CN": "通过 Anthropic Messages API 使用完整的 Claude 订阅。", }, providerType: "anthropic", resourceType: RESOURCE_TYPE, diff --git a/server/plugins/build-in/claude-auth/resources.ts b/server/plugins/build-in/claude-auth/resources.ts index 206a1ff7f..8dc46cea5 100644 --- a/server/plugins/build-in/claude-auth/resources.ts +++ b/server/plugins/build-in/claude-auth/resources.ts @@ -11,9 +11,9 @@ export const claudeAccounts: ResourceSupport = { return { displayName: data.displayName, description: { - "en-US": "Experimental subscription access. Anthropic may restrict this connection.", - "ru-RU": "Экспериментальный доступ по подписке. Anthropic может ограничить подключение.", - "zh-CN": "实验性订阅访问。Anthropic 可能限制此连接。", + "en-US": "Full Claude subscription account.", + "ru-RU": "Полноценный аккаунт подписки Claude.", + "zh-CN": "完整的 Claude 订阅账号。", }, }; },