diff --git a/README.md b/README.md index 639b721..062ae2c 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ After `pnpm build`, restart `dsh web` to pick up changes. ## Layout - `src/index.ts` — plugin entry: config schema, adapter registration, auth-change re-announce, RPC wiring -- `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` RPC channel +- `src/auth/` — PKCE/JWT helpers, token store, OAuth flow engine (temp loopback callback server), Claude Code credential reader (Keychain/file), `/subscriptions-auth` endpoints (the `/api/subscriptions-auth` Fetch route on DSH v0.1.2-alpha.1+, an RPC channel on v0.1.1-rc.2) - `src/providers/` — per-provider OAuth constants/exchange/refresh + `LlmAdapter`s, multi-account token plumbing (`accounts.ts`), the pool (`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`), and `rate-limit.ts` (reset-instant parsing + retry policy) - `src/translate/` — dsh `Message[]` ⟷ OpenAI Responses / Anthropic Messages wire formats, SSE → `StreamChunk` - `src/tools/` — `x_search`, `image_generate`, and `video_generate` diff --git a/README.zh.md b/README.zh.md index 9f124dc..4d293b9 100644 --- a/README.zh.md +++ b/README.zh.md @@ -266,7 +266,7 @@ pnpm test # 编译后跑 node --test 单测 ## 目录结构 - `src/index.ts` —— 插件入口:配置 schema、adapter 注册、登录态变更通告、RPC 接线 -- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` RPC 通道 +- `src/auth/` —— PKCE/JWT 工具、token 存储、OAuth 流程引擎(临时本地回调服务)、Claude Code 凭据读取器(Keychain/文件)、`/subscriptions-auth` 端点(DSH v0.1.2-alpha.1 起走 `/api/subscriptions-auth` Fetch 路由,v0.1.1-rc.2 走 RPC 通道) - `src/providers/` —— 各 provider 的 OAuth 常量/换发/刷新 + `LlmAdapter` 实现,多账号 token 管理(`accounts.ts`),模型池(`pool.ts` + `pool-health.ts` / `pool-usage.ts` / `pool-family.ts`),以及 `rate-limit.ts`(限流重开时刻解析 + 重试策略) - `src/translate/` —— dsh `Message[]` 与 OpenAI Responses / Anthropic Messages 格式互转,SSE → `StreamChunk` - `src/tools/` —— `x_search`、`image_generate` 与 `video_generate` diff --git a/package.json b/package.json index b635b69..454b92c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-plugin-subscriptions", - "version": "0.8.0", + "version": "0.8.1", "description": "Use ChatGPT (Codex), Claude, Grok (X Premium), and GitHub Copilot subscriptions as DeepSeek Harness LLM providers, with OAuth login from the web Settings page", "license": "MIT", "repository": { @@ -57,10 +57,10 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", - "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2 || ^0.1.2-alpha.1", - "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2 || ^0.1.2-alpha.1", - "@deepseek-ai/dsh-llm": "^0.1.1-rc.2 || ^0.1.2-alpha.1", - "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-alpha.1", + "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1", + "@deepseek-ai/dsh-home-paths": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1", + "@deepseek-ai/dsh-llm": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1", + "@deepseek-ai/dsh-tools": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.3-alpha.1 || ^0.1.5-alpha.1", "@deepseek-ai/schemastery": "^3.18.1" }, "devDependencies": { @@ -71,7 +71,7 @@ "@deepseek-ai/dsh-client-locale": "0.1.2-alpha.3", "@deepseek-ai/dsh-client-ui-settings": "0.1.2-alpha.3", "@deepseek-ai/dsh-client-ui-conversation": "0.1.2-alpha.3", - "@deepseek-ai/dsh-client-ui-commands": "0.1.2-alpha.3", + "@deepseek-ai/dsh-client-ui-commands": "0.1.5-alpha.1", "@deepseek-ai/dsh-client-ui-primitives": "0.1.2-alpha.3", "@deepseek-ai/dsh-client-ui-renderer": "0.1.2-alpha.3", "@deepseek-ai/dsh-client-ui-slots": "0.1.2-alpha.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bdc99a..65aea2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: 0.1.2-alpha.3 version: 0.1.2-alpha.3(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-commands': - specifier: 0.1.2-alpha.3 - version: 0.1.2-alpha.3(@deepseek-ai/cordis@4.0.2) + specifier: 0.1.5-alpha.1 + version: 0.1.5-alpha.1(@deepseek-ai/cordis@4.0.2) '@deepseek-ai/dsh-client-ui-conversation': specifier: 0.1.2-alpha.3 version: 0.1.2-alpha.3(@deepseek-ai/cordis@4.0.2)(typescript@5.9.3) @@ -152,8 +152,8 @@ packages: peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-client-ui-commands@0.1.2-alpha.3': - resolution: {integrity: sha512-t8bVVVDDtLzP0Tg/j3+KB06cgAK0LOGpG0gczJ5emwi4/pfZLuCqBoC0WCv2VILIh5H09TFP4H9jzCE9Jawh1Q==} + '@deepseek-ai/dsh-client-ui-commands@0.1.5-alpha.1': + resolution: {integrity: sha512-v/WpuT5PNl9QYa0vOo9wR54wRFPgLuYPYGFLikUlYePFxlm91YGcoYTLD1g8b4i3TTVkyksy9NkCEOy7jeNuDg==} peerDependencies: '@deepseek-ai/cordis': ^4.0.2 @@ -456,24 +456,28 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-beta.45': resolution: {integrity: sha512-tdy8ThO/fPp40B81v0YK3QC+KODOmzJzSUOO37DinQxzlTJ026gqUSOM8tzlVixRbQJltgVDCTYF8HNPRErQTA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-x64-gnu@1.0.0-beta.45': resolution: {integrity: sha512-lS082ROBWdmOyVY/0YB3JmsiClaWoxvC+dA8/rbhyB9VLkvVEaihLEOr4CYmrMse151C4+S6hCw6oa1iewox7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-beta.45': resolution: {integrity: sha512-Hi73aYY0cBkr1/SvNQqH8Cd+rSV6S9RB5izCv0ySBcRnd/Wfn5plguUoGYwBnhHgFbh6cPw9m2dUVBR6BG1gxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-beta.45': resolution: {integrity: sha512-fljEqbO7RHHogNDxYtTzr+GNjlfOx21RUyGmF+NrkebZ8emYYiIqzPxsaMZuRx0rgZmVmliOzEp86/CQFDKhJQ==} @@ -1120,7 +1124,7 @@ snapshots: '@deepseek-ai/cordis': 4.0.2 '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/dsh-client-ui-commands@0.1.2-alpha.3(@deepseek-ai/cordis@4.0.2)': + '@deepseek-ai/dsh-client-ui-commands@0.1.5-alpha.1(@deepseek-ai/cordis@4.0.2)': dependencies: '@deepseek-ai/cordis': 4.0.2 clsx: 2.1.1 diff --git a/src/auth/claude-code-creds.ts b/src/auth/claude-code-creds.ts index 6e412a2..c2f581d 100644 --- a/src/auth/claude-code-creds.ts +++ b/src/auth/claude-code-creds.ts @@ -28,7 +28,12 @@ interface CredentialBlob { const DEFAULT_SCOPES = 'user:profile user:inference user:sessions:claude_code user:mcp_servers' function toSession(data: RawCreds): ClaudeSession | undefined { - if (typeof data.accessToken !== 'string' || typeof data.refreshToken !== 'string' || typeof data.expiresAt !== 'number') { + // Empty-string tokens (seen from a corrupted Keychain item left by a Claude + // Code logout) pass the typeof gate but are useless and would poison the + // auth store — a single such entry fails every provider's status read. + if (typeof data.accessToken !== 'string' || data.accessToken.length === 0 + || typeof data.refreshToken !== 'string' || data.refreshToken.length === 0 + || typeof data.expiresAt !== 'number' || !Number.isFinite(data.expiresAt)) { return undefined } const scopes = Array.isArray(data.scopes) ? data.scopes.join(' ') : typeof data.scopes === 'string' ? data.scopes : DEFAULT_SCOPES diff --git a/src/auth/rpc-fetch.ts b/src/auth/rpc-fetch.ts new file mode 100644 index 0000000..e17d5d1 --- /dev/null +++ b/src/auth/rpc-fetch.ts @@ -0,0 +1,77 @@ +/** + * The `/subscriptions-auth` endpoints served as one exact `/api` Fetch route. + * + * dsh 0.1.5 broke `connection.rpc.handle` for every consumer: the channel is + * mounted through the connection plugin's own `webServer`, which that plugin + * no longer injects, so registration throws and the Settings page's POSTs + * fall through to the SPA (405). An exact Fetch route only enters the + * connection's route map, dispatched under its already-mounted `/api` prefix, + * and the registry exists on every host since dsh 0.1.2-alpha.1. + * + * The browser keeps calling through `rpc.call('/api', 'subscriptions-auth', + * { endpoint, payload })`, which posts the client-request envelope to this + * path; the codec answers with the same server-response envelope the host's + * channel bridge writes, so every caller sees the unchanged result shape. + * One route with the endpoint in the payload keeps a single registration + * instead of a path list that must track every endpoint. + */ + +import type { RpcResult } from '../compat.js' + +/** Channel-relative endpoint of the route below the shared `/api` channel. */ +export const SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT = 'subscriptions-auth' + +/** Absolute path of the exact Fetch route. */ +export const SUBSCRIPTIONS_AUTH_ROUTE = `/api/${SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT}` + +/** Decoded endpoint handler shared with the legacy channel; never throws. */ +export type SubscriptionsAuthHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +function badRequest(message: string): RpcResult { + return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } } +} + +function respond(rpcId: string, result: RpcResult): Response { + return Response.json({ type: 'server-response', rpcId, result }) +} + +/** + * Build the Fetch implementation of the `/api/subscriptions-auth` route. + * @param handler - the endpoint dispatcher the legacy channel also uses. + * @returns a Fetch handler for authenticated POSTs the host has already let through its trust fence. + */ +export function subscriptionsAuthFetch(handler: SubscriptionsAuthHandler): (request: Request) => Promise { + return async (request) => { + // Same content-type and body rejections as the host's channel bridge. + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/json') { + return new Response('content type must be application/json', { status: 415 }) + } + let body: unknown + try { + body = await request.json() + } catch { + return new Response('body is not JSON', { status: 400 }) + } + + const message = (typeof body === 'object' && body !== null ? body : {}) as Record + if (message.type !== 'client-request' || typeof message.rpcId !== 'string' || typeof message.method !== 'string') { + return respond(typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request', + badRequest('invalid client-request message')) + } + if (message.method !== SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT) { + return respond(message.rpcId, badRequest( + `method ${JSON.stringify(message.method)} does not match endpoint "${SUBSCRIPTIONS_AUTH_ROUTE_ENDPOINT}"`)) + } + const inner = message.payload + const endpoint = typeof inner === 'object' && inner !== null ? (inner as Record).endpoint : undefined + if (typeof endpoint !== 'string' || endpoint.length === 0) { + return respond(message.rpcId, badRequest('payload.endpoint must be a non-empty string')) + } + return respond(message.rpcId, await handler(endpoint, (inner as Record).payload, request.signal)) + } +} diff --git a/src/auth/rpc.ts b/src/auth/rpc.ts index b5b912d..eea863a 100644 --- a/src/auth/rpc.ts +++ b/src/auth/rpc.ts @@ -1,18 +1,21 @@ /** - * The `/subscriptions-auth` host RPC channel the web Settings page drives. The - * channel is registered only when a host `connection` service exists (the web - * profile); headless compositions load the plugin without it. All business - * outcomes are returned as RpcResult values; handlers never throw. + * The `/subscriptions-auth` endpoints the web Settings page drives, served as + * the `/api/subscriptions-auth` Fetch route (see rpc-fetch.ts) or, on rc.2, as + * a host RPC channel. They are registered only when a host `connection` + * service exists (the web profile); headless compositions load the plugin + * without it. All business outcomes are returned as RpcResult values; + * handlers never throw. */ import type { Context } from '@deepseek-ai/cordis' -import type { ConnectionRpcHandler, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { ConnectionFetchMethod, ConnectionFetchRoute, ConnectionRpcHandler, HostConnectionFetch, HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' import type { RpcResult } from '../compat.js' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { PROVIDER_IDS, type ProviderId } from './store.js' import type { ProviderUsage } from '../providers/common.js' import type { ProxyConfigView, ProxyDraft, ProxyInput, ProxyTestResult } from '../http.js' +import { SUBSCRIPTIONS_AUTH_ROUTE, subscriptionsAuthFetch } from './rpc-fetch.js' /** The RPC channel this plugin registers on the host connection. */ export const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth' @@ -195,6 +198,22 @@ type RpcHandleCompat = ( options?: { readonly authority: 'loopback' }, ) => () => Promise +/** + * Exact Fetch route across the dsh lines, widening two fields the + * 0.1.2-alpha types this package builds against declare too narrowly: + * + * - `requestBody`: 0.1.3-alpha.1 added this required mode (the bridge reads it + * before touching the body); the 0.1.2-alpha runtime ignores the extra field. + * - `methods`: `ConnectionFetchMethod` is `'GET' | 'HEAD'` until 0.1.3-alpha.2 + * widens it to include `'POST'`. The 0.1.2 runtime never validates the names + * (`assertFetchRoute` only checks the path, arity and duplicates) and matches + * with `route.methods.has(request.method)`, so a POST route is served there too. + */ +type FetchRouteCompat = Omit & { + readonly methods: readonly (ConnectionFetchMethod | 'POST')[] + readonly requestBody: 'buffered' | 'streaming' +} + function ok(value: unknown): RpcResult { return { ok: true, value } } @@ -424,8 +443,15 @@ async function dispatch( return ok({ ok: true }) } case 'status': { + // One provider's failure (a corrupt store entry, a broken flow) must not + // blind the whole page: it degrades to an error detail on that provider + // while the others still report their real status. const entries = await Promise.all(PROVIDER_IDS.map( - async provider => [provider, await controller.status(provider)] as const, + async provider => [provider, await controller.status(provider).catch((error: unknown) => ({ + busy: false, + accounts: [], + detail: error instanceof Error ? error.message : String(error), + }) satisfies ProviderStatus)] as const, )) return ok({ providers: Object.fromEntries(entries) }) } @@ -490,7 +516,9 @@ async function dispatch( } /** - * Register the `/subscriptions-auth` RPC channel when a host connection exists. + * Register the `/subscriptions-auth` endpoints when a host connection exists: + * as the exact `/api/subscriptions-auth` Fetch route where the host has the + * Fetch registry (dsh 0.1.2-alpha.1+), else as the legacy RPC channel (rc.2). * @param ctx - the plugin context (headless profiles have no `connection`). * @param controller - the auth operations backing the endpoints. * @param speed - the per-session speed-tier state backing the Speed toggle. @@ -505,23 +533,36 @@ export function registerAuthRpc( modelDefaults: ModelDefaultsController | undefined = undefined, providerSettings: ProviderSettingsController | undefined = undefined, ): void { + const handler: ConnectionRpcHandler = async (endpoint, payload, signal) => { + try { + return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal, providerSettings) + } catch (error) { + return failure(error) + } + } // `connection` is not in this plugin's inject list (headless compositions // lack it), so its startup order is unconstrained: defer registration until // the service exists instead of probing once at apply time. ctx.inject(['connection'], (ctx) => { const connection = ctx.get('connection') as HostConnectionHandle + // rc.2 has no Fetch registry despite the type; 0.1.5 can only serve this way + // (see rpc-fetch.ts), and the browser half picks the matching transport. + const fetchRoutes = (connection as { readonly fetch?: HostConnectionFetch }).fetch + if (fetchRoutes !== undefined) { + const route: FetchRouteCompat = { + path: SUBSCRIPTIONS_AUTH_ROUTE, + methods: ['POST'], + requestBody: 'buffered', + fetch: subscriptionsAuthFetch(handler), + } + ctx.effect( + () => fetchRoutes.register(route as ConnectionFetchRoute), + `dsh-plugin-subscriptions: ${SUBSCRIPTIONS_AUTH_ROUTE} fetch route`, + ) + return + } ctx.effect( - () => (connection.rpc.handle as RpcHandleCompat)( - SUBSCRIPTIONS_AUTH_CHANNEL, - async (endpoint, payload, signal) => { - try { - return await dispatch(controller, speed, proxy, modelDefaults, endpoint, payload, signal, providerSettings) - } catch (error) { - return failure(error) - } - }, - { authority: 'loopback' }, - ), + () => (connection.rpc.handle as RpcHandleCompat)(SUBSCRIPTIONS_AUTH_CHANNEL, handler, { authority: 'loopback' }), 'dsh-plugin-subscriptions: /subscriptions-auth rpc channel', ) }) diff --git a/src/auth/store.ts b/src/auth/store.ts index 36bb60a..2e1c0ca 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -203,7 +203,15 @@ export async function loadStore(path = authFilePath()): Promise { return parseStore(text, path) } -/** Parse, validate, and migrate store JSON read from `path`. */ +/** + * Parse and migrate store JSON read from `path`. An ACCOUNT entry whose shape + * is invalid (empty or missing tokens — corruption seen in the wild from a + * broken keychain import) is SKIPPED instead of rejected: one bad entry must + * not blind every provider's status read, and a session without tokens is + * unusable by definition, so nothing of value is discarded. The next write + * persists the store without the skipped entry. Structural failures (invalid + * JSON, a non-object file) still throw — those say the file itself is broken. + */ function parseStore(text: string, path: string): SessionMap { let parsed: unknown try { @@ -220,12 +228,16 @@ function parseStore(text: string, path: string): SessionMap { const entry = raw[provider] if (entry === undefined) continue if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new Error(`subscriptions auth store: entry "${provider}" is not an object; fix or delete the store file`) + console.warn(`subscriptions auth store: entry "${provider}" is not an object; skipped`) + continue } const record = entry as Record if (typeof record.accessToken === 'string') { // Single-account format: wrap the bare session, preserving every field. - assertSessionShape(provider, '(legacy)', record) + if (!isValidSessionShape(record)) { + console.warn(`subscriptions auth store: legacy entry "${provider}" has no usable tokens; skipped`) + continue + } const session = record as unknown as StoredSession const key = accountKeyOf(provider, session) ;(store as Record)[provider] = { default: key, accounts: { [key]: session } } @@ -233,21 +245,41 @@ function parseStore(text: string, path: string): SessionMap { } const accounts = record.accounts if (typeof accounts !== 'object' || accounts === null || Array.isArray(accounts)) { - throw new Error( - `subscriptions auth store: entry "${provider}" has no accounts map; fix or delete the store file`, - ) + console.warn(`subscriptions auth store: entry "${provider}" has no accounts map; skipped`) + continue } if (record.default !== undefined && typeof record.default !== 'string') { - throw new Error(`subscriptions auth store: entry "${provider}" default is not a string; fix or delete the store file`) + console.warn(`subscriptions auth store: entry "${provider}" default is not a string; skipped`) + continue } + const kept: Record = {} for (const [account, session] of Object.entries(accounts)) { - assertSessionShape(provider, account, session) + if (isValidSessionShape(session)) { + kept[account] = session as StoredSession + } else { + console.warn( + `subscriptions auth store: entry "${provider}/${account}" has no usable accessToken/refreshToken/expiresAt; skipped`, + ) + } } - ;(store as Record)[provider] = record + if (Object.keys(kept).length === 0) continue + const validDefault = record.default === undefined || record.default in kept + ? record.default as string | undefined + : Object.keys(kept)[0] + ;(store as Record)[provider] = { ...record, default: validDefault, accounts: kept } } return store } +/** Whether a value carries the fields every stored session needs (non-empty tokens). */ +function isValidSessionShape(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false + const entry = value as Record + return typeof entry.accessToken === 'string' && entry.accessToken.length > 0 + && typeof entry.refreshToken === 'string' && entry.refreshToken.length > 0 + && typeof entry.expiresAt === 'number' && Number.isFinite(entry.expiresAt) +} + /** Persist the whole store atomically with owner-only permissions. */ async function writeStore(store: SessionMap, path: string): Promise { await mkdir(dirname(path), { recursive: true }) @@ -335,10 +367,15 @@ export async function getAccountSession( /** * Write one account's session, preserving the others. The first account of a * provider becomes its default. + * + * The session is validated before it lands: a corrupt entry written here + * would fail every later read of the whole store (one bad entry breaks all + * providers' status), so the write path must be as strict as the read path. * @param provider - the provider route. * @param account - the account key (see {@link accountKeyOf}). * @param session - the fresh session from a login or refresh. * @param path - store file path; defaults to {@link authFilePath}. + * @throws when the session is missing accessToken/refreshToken/expiresAt. */ export async function saveAccountSession( provider: K, @@ -346,6 +383,7 @@ export async function saveAccountSession( session: SessionOf, path = authFilePath(), ): Promise { + assertSessionShape(provider, account, session) return serialize(path, async () => { const store = await loadStore(path) const entry = store[provider] as ProviderAccounts> | undefined diff --git a/src/client/SpeedSelect.tsx b/src/client/SpeedSelect.tsx index 19039aa..7048652 100644 --- a/src/client/SpeedSelect.tsx +++ b/src/client/SpeedSelect.tsx @@ -80,7 +80,7 @@ export type SpeedSelectProps = PropsRuntime<'conversation.input.right'> * at all) simply keeps the toggle hidden. */ export function createSpeedLoader( - connection: ConnectionHandle, + connection: Pick, models: () => ModelDirectoriesLike | undefined, sessionId: string, ): SpeedSelectInjected['loadSpeed'] { @@ -97,7 +97,7 @@ export function createSpeedLoader( /** The `setSpeed` half of the inject face: boolean outcome for the component's busy state. */ export function createSpeedSetter( - connection: ConnectionHandle, + connection: Pick, sessionId: string, ): SpeedSelectInjected['setSpeed'] { return tier => callSubscriptionsAuth(connection.rpc, 'setSpeed', { sessionId, tier }) diff --git a/src/client/SubscriptionsSection.tsx b/src/client/SubscriptionsSection.tsx index b255569..a4e565c 100644 --- a/src/client/SubscriptionsSection.tsx +++ b/src/client/SubscriptionsSection.tsx @@ -493,13 +493,18 @@ export function SubscriptionsSection(props: SubscriptionsSectionProps) { let response: StatusResponse try { response = await callSubscriptionsAuth(rpc, 'status', {}) - } catch { + } catch (error) { // A failed poll must not kill the page; busy providers keep polling and - // the action paths report their own errors. + // the action paths report their own errors. But staying silent turns a + // persistent failure into an endless "Checking…" — show it instead. + const message = error instanceof Error ? error.message : String(error) + for (const { id } of PROVIDERS) setProviderError(id, message) return } if (!mountedRef.current) return setStatuses(response.providers) + // The poll recovered: drop any error line a previous failed poll left. + for (const { id } of PROVIDERS) setProviderError(id, undefined) for (const { id } of PROVIDERS) { const status = response.providers[id] if (status.accounts.length > 0 || !status.busy) { @@ -513,7 +518,7 @@ export function SubscriptionsSection(props: SubscriptionsSectionProps) { }) } } - }, [rpc, stopPolling]) + }, [rpc, stopPolling, setProviderError]) const startPolling = useCallback((provider: SubscriptionProvider): void => { if (pollersRef.current.has(provider)) return diff --git a/src/client/index.ts b/src/client/index.ts index f1cf1ce..f182d6c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -23,6 +23,7 @@ import type { CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/clie // allowImportingTsExtensions/rewriteRelativeImportExtensions pair; under // nodenext the .js specifier resolves to the .tsx source (see README note). import { SubscriptionsSection } from './SubscriptionsSection.js' +import { routeSubscriptionsAuth } from './subscriptions-rpc.js' import type { SubscriptionsSectionInjected } from './SubscriptionsSection.js' import { ImageGenerateToolview, createImageLoader } from './ImageGenerateToolview.js' import type { ImageGenerateToolviewInjected } from './ImageGenerateToolview.js' @@ -79,8 +80,9 @@ export function apply(ctx: ClientContext): void { return () => style.remove() }, 'dsh-plugin-subscriptions: settings panel breathing room') // The shell's Context merge types `connection` as the host handle; in the - // browser shell the same key holds the full client ConnectionHandle. - const connection = ctx.get('connection') as unknown as ConnectionHandle + // browser shell the same key holds the full client ConnectionHandle. Its + // `/subscriptions-auth` calls follow the transport the node half registered. + const connection = routeSubscriptionsAuth(ctx.get('connection') as unknown as ConnectionHandle) const t = ctx.locale.bind(NS) as SubscriptionsSectionInjected['t'] const injected = (): SubscriptionsSectionInjected => ({ rpc: connection.rpc, t }) ctx.slots.inject('settings.section', () => ctx.slots.register({ @@ -150,7 +152,14 @@ export function apply(ctx: ClientContext): void { const command = scope.get('commandUi') as CommandUiContract scope.effect(() => command.register({ name: 'fast', - description: t('commandFast'), + // dsh 0.1.5-alpha made `description` a locale resolver evaluated per + // candidate pass (commit 5d9603b76, "feat(web): localize slash command + // descriptions"); earlier lines read the value as a plain string. The + // bare string threw `contribution.description is not a function` inside + // the registry's candidate pass on 0.1.5, aborting the whole `/` source + // and hiding every host command — /plan, /model, /goal, ... — not just + // /fast. Older lines render a function child as empty copy, no crash. + description: () => t('commandFast'), available: () => true, ui: { kind: 'popupSelect', diff --git a/src/client/subscriptions-rpc.ts b/src/client/subscriptions-rpc.ts index 5093b02..73c9cf7 100644 --- a/src/client/subscriptions-rpc.ts +++ b/src/client/subscriptions-rpc.ts @@ -5,6 +5,31 @@ const SUBSCRIPTIONS_AUTH_CHANNEL = '/subscriptions-auth' /** Business error returned by the `/subscriptions-auth` channel (error branch message). */ export class SubscriptionsAuthError extends Error {} +/** + * Point the handle's `/subscriptions-auth` calls at the transport the node half + * registered. Hosts since dsh 0.1.2-alpha.1 carry exact `/api` Fetch routes and + * the node half serves the endpoints there (0.1.5 can no longer mount the + * legacy channel), so each call becomes `rpc.call('/api', 'subscriptions-auth', + * { endpoint, payload })`. rc.2 has no Fetch routes; it is recognised by its + * `.api` face, which 0.1.2-alpha.1 removed, and keeps the legacy channel. + * Keying on the frozen rc.2 face rather than a newer member keeps future hosts + * on the Fetch route. + * @param connection - the client connection handle. + * @returns the RPC face this plugin consumes, with `/subscriptions-auth` calls routed. + */ +export function routeSubscriptionsAuth(connection: Pick): Pick { + const rpc = connection.rpc + if ('api' in connection) return { rpc } + return { + rpc: { + ...rpc, + call: (channel, endpoint, payload, signal) => channel === SUBSCRIPTIONS_AUTH_CHANNEL + ? rpc.call('/api', 'subscriptions-auth', { endpoint, payload }, signal) + : rpc.call(channel, endpoint, payload, signal), + }, + } +} + /** * Call one `/subscriptions-auth` endpoint and unwrap the business result. * Shared by the settings section and the composer Speed toggle. diff --git a/src/index.ts b/src/index.ts index 11d1619..cbc0189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /** * dsh-plugin-subscriptions: register OAuth-subscription LLM providers * (ChatGPT/Codex, Claude, Grok, GitHub Copilot) on `ctx.llm`, and expose the `/subscriptions-auth` - * RPC channel the web Settings page uses to run the logins. The token store - * lives at `~/.dsh/plugins/subscriptions/auth.json`; the channel registers only when + * endpoints the web Settings page uses to run the logins. The token store + * lives at `~/.dsh/plugins/subscriptions/auth.json`; the endpoints register only when * a host `connection` service exists, so headless compositions load fine. * @module dsh-plugin-subscriptions */ @@ -18,6 +18,7 @@ import type { } from '@deepseek-ai/dsh-llm' // Type-only: activates the `ctx.tools` Context merge for the inject block. import type {} from '@deepseek-ai/dsh-tools' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { OAuthFlowManager, type OAuthAttempt } from './auth/oauth-flow.js' import { DeviceFlowManager, type DeviceAttempt } from './auth/device-flow.js' @@ -112,7 +113,7 @@ import { createXSearchTool } from './tools/x-search.js' import { createImageGenerateTool } from './tools/image-generate.js' import { createVideoGenerateTool, videosDirectory } from './tools/video-generate.js' import { proxiedFetch, proxyGetConfig, proxySetConfig, proxyTestConnection } from './http.js' -import { ProviderSettingsStore, PROVIDER_TOOLS, validatePreferences } from './provider-settings.js' +import { ProviderSettingsStore, PROVIDER_TOOLS, validatePreferences, type SubscriptionTool } from './provider-settings.js' export type { ModelEntry, ProviderUsage, UsageWindow } from './providers/common.js' export type { RateLimitConfig, RateLimitWait } from './providers/rate-limit.js' @@ -1056,19 +1057,74 @@ export function apply(ctx: Context, config: Config): void { // x_search and video_generate follow the grok provider; image_generate // prefers the codex provider and falls back to grok. ctx.inject(['tools'], (toolsCtx) => { + /** + * Register one tool under its canonical name, falling back to the alias + * when another plugin already owns it (issue #76: e.g. @liustack/modsearch + * registers its own `x_search`; the duplicate insert throws and used to + * abort the whole apply, taking the providers and the auth channel down + * with it). A failed register leaves no state behind, so the alias attempt + * is safe; when even the alias is taken the tool is skipped with a warning + * and the rest of the plugin keeps working. + * @param canonical - the default tool name. + * @param alias - the fallback name, namespaced to this plugin. + * @param build - constructs the definition for one candidate name. + * @returns the name the tool is registered under, or undefined when skipped. + */ + const registerTool = ( + canonical: string, + alias: string, + build: (name: string) => ToolDefinition, + ): string | undefined => { + let lastRegisterError = '' + const attempt = (candidate: string): boolean => { + try { + toolsCtx.tools.register(build(candidate)) + return true + } catch (error) { + lastRegisterError = error instanceof Error ? error.message : String(error) + return false + } + } + // A visible `get` probe (present on every dsh line this plugin supports; + // hand-built test contexts may omit it) skips the doomed attempt; the + // try/catch stays for the race where another plugin registers between + // probe and insert. + const probe = (toolsCtx.tools as { get?: (name: string) => unknown }).get + const free = (candidate: string): boolean => + typeof probe !== 'function' || probe.call(toolsCtx.tools, candidate) === undefined + if (free(canonical) && attempt(canonical)) return canonical + if (free(alias) && attempt(alias)) { + onWarn(`tool "${canonical}" is already registered by another plugin; this plugin's tool is mounted as "${alias}"`) + return alias + } + onWarn(`tool "${canonical}" could not be registered (${lastRegisterError || 'name owned by another plugin'}); skipping it`) + return undefined + } + + // Settings key → registered tool name. The Settings-page switches key on + // the stable settings name; the deny list needs the name that actually + // registered, which the alias changes. + const toolNames = new Map() if (grokTokens !== undefined) { - toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens })) - toolsCtx.tools.register(createVideoGenerateTool({ tokens: grokTokens })) + const xSearch = registerTool('x_search', 'grok_x_search', + candidate => createXSearchTool({ tokens: grokTokens, name: candidate })) + if (xSearch !== undefined) toolNames.set('x_search', xSearch) + const video = registerTool('video_generate', 'grok_video_generate', + candidate => createVideoGenerateTool({ tokens: grokTokens, name: candidate })) + if (video !== undefined) toolNames.set('video_generate', video) } if (codexTokens !== undefined || grokTokens !== undefined) { - toolsCtx.tools.register(createImageGenerateTool({ - imagePool, - ...codexTokens === undefined ? {} : { codexTokens }, - ...grokTokens === undefined ? {} : { grokTokens }, - resolveAttachments, - resolveLlm: () => ctx.get('llm'), - providerEnabled: (provider, createdAt) => preferences.toolEnabled(provider, 'image_generate', createdAt), - })) + const image = registerTool('image_generate', 'subscriptions_image_generate', + candidate => createImageGenerateTool({ + imagePool, + ...codexTokens === undefined ? {} : { codexTokens }, + ...grokTokens === undefined ? {} : { grokTokens }, + resolveAttachments, + resolveLlm: () => ctx.get('llm'), + providerEnabled: (provider, createdAt) => preferences.toolEnabled(provider, 'image_generate', createdAt), + name: candidate, + })) + if (image !== undefined) toolNames.set('image_generate', image) } // Restrictions are scoped to each agent. Keep global definitions registered // so already-open sessions retain both their schemas and execution path. @@ -1077,13 +1133,15 @@ export function apply(ctx: Context, config: Config): void { const deny: string[] = [] if (grokTokens !== undefined) { for (const tool of ['x_search', 'video_generate'] as const) { - if (!preferences.toolEnabled('grok', tool, at)) deny.push(tool) + const registered = toolNames.get(tool) + if (registered !== undefined && !preferences.toolEnabled('grok', tool, at)) deny.push(registered) } } if ((codexTokens !== undefined || grokTokens !== undefined) && !(codexTokens !== undefined && preferences.toolEnabled('codex', 'image_generate', at)) && !(grokTokens !== undefined && preferences.toolEnabled('grok', 'image_generate', at))) { - deny.push('image_generate') + const registered = toolNames.get('image_generate') + if (registered !== undefined) deny.push(registered) } if (deny.length) agent.ctx.tools.restrict({ deny }) }) diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 5bb89ef..9699016 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -100,14 +100,44 @@ export const claudeRateLimitReset: RateLimitResetReader = (response, body, now) * so these headers impersonate the CLI; the harness attribution user-agent * cannot be sent here (one user-agent slot, and the CLI's wins). */ -export const CLAUDE_CLI_FALLBACK_VERSION = '2.1.234' +export const CLAUDE_CLI_FALLBACK_VERSION = '2.1.263' + +/** + * Candidate invocations, in order of preference. + * + * npm installs Claude Code on Windows as `claude.cmd` (a batch shim); no + * `claude.exe` exists. `execFileSync` cannot run either name directly there: + * the extensionless one is `ENOENT` (no `PATHEXT` resolution), and the `.cmd` + * is `EINVAL` since Node's CVE-2024-27980 fix refuses to spawn batch files + * without a shell. Both Windows candidates therefore go through `cmd.exe`, + * with the argument inside the command string so that Node does not warn + * about unescaped shell arguments (DEP0190). + */ +const CLAUDE_VERSION_PROBES: readonly (readonly [string, readonly string[], { shell?: boolean }])[] = + process.platform === 'win32' + ? [ + ['claude --version', [], { shell: true }], + ['claude.cmd --version', [], { shell: true }], + ] + : [['claude', ['--version'], {}]] export function detectClaudeVersion(): string { - try { - const raw = execFileSync('claude', ['--version'], { timeout: 3000, encoding: 'utf8' }) - const match = raw.match(/^(\d+\.\d+\.\d+)/) - if (match) return match[1] - } catch {} + for (const [command, args, options] of CLAUDE_VERSION_PROBES) { + try { + // 3s was tight once cmd.exe startup is in the path; a timeout here + // silently costs the real version and pins the stale fallback. + const raw = execFileSync(command, [...args], { + timeout: 10_000, + encoding: 'utf8', + // Keep CLI stderr chatter out of the parsed text. + stdio: ['ignore', 'pipe', 'ignore'], + ...options, + }) + // Unanchored: the shell path may prefix the version with other output. + const match = raw.match(/(\d+\.\d+\.\d+)/) + if (match) return match[1] + } catch {} + } return CLAUDE_CLI_FALLBACK_VERSION } diff --git a/src/providers/grok.ts b/src/providers/grok.ts index c70a0d2..d53b1e9 100644 --- a/src/providers/grok.ts +++ b/src/providers/grok.ts @@ -353,12 +353,15 @@ export async function fetchGrokUsage( const payload = await response.json() as { config?: GrokBillingConfig | null; subscriptionTier?: string } const config = typeof payload.config === 'object' && payload.config !== null ? payload.config : {} const windows: UsageWindow[] = [] - if (typeof config.creditUsagePercent === 'number' && Number.isFinite(config.creditUsagePercent)) { + if (config.currentPeriod || (typeof config.creditUsagePercent === 'number' && Number.isFinite(config.creditUsagePercent))) { const kind: UsageWindow['kind'] = config.currentPeriod?.type === 'USAGE_PERIOD_TYPE_WEEKLY' ? 'weekly' : 'other' const resetsAt = grokResetsAt(config.currentPeriod?.end) - windows.push({ kind, usedPercent: config.creditUsagePercent, ...resetsAt === undefined ? {} : { resetsAt } }) + const usedPercent = typeof config.creditUsagePercent === 'number' && Number.isFinite(config.creditUsagePercent) + ? config.creditUsagePercent + : 0 + windows.push({ kind, usedPercent, ...resetsAt === undefined ? {} : { resetsAt } }) } else if (typeof config.monthlyLimit?.val === 'number' && config.monthlyLimit.val > 0) { const used = typeof config.used?.val === 'number' ? config.used.val : 0 const resetsAt = grokResetsAt(config.billingPeriodEnd) diff --git a/src/tools/image-generate.ts b/src/tools/image-generate.ts index 8630bf0..9153bdb 100644 --- a/src/tools/image-generate.ts +++ b/src/tools/image-generate.ts @@ -58,6 +58,11 @@ export interface ImageGenerateToolOptions { resolveAttachments?: () => AttachmentStore | undefined /** Lazy llm-service lookup for the image-capability route check. */ resolveLlm?: () => LlmRuntime | undefined + /** + * Registered tool name. Defaults to `image_generate`; the plugin falls back + * to an alias when another plugin already owns that name (issue #76). + */ + name?: string } /** The wire request body for one generation call. */ @@ -319,8 +324,9 @@ function imageGenerateText(value: ImageGenerateValue): ContentBlock { */ export function createImageGenerateTool(options: ImageGenerateToolOptions): ToolDefinition { const imagePool = options.imagePool ?? new ImageAccountPool() + const toolName = options.name ?? 'image_generate' return defineTool({ - name: 'image_generate', + name: toolName, description: 'Generate an image with the ChatGPT subscription (gpt-image-2) or the Grok ' + 'subscription (grok-imagine-image-2.0) and save it as an image file. The `provider` ' + 'parameter picks the preferred provider (default gpt); when the preferred one is logged ' @@ -402,7 +408,7 @@ export function createImageGenerateTool(options: ImageGenerateToolOptions): Tool }, presentCall: args => ({ card: 'generic', - title: `image_generate${args.referenceImages === undefined ? '' : ` (edit, ${args.referenceImages.length} images)`}: ${truncate(args.prompt)}`, + title: `${toolName}${args.referenceImages === undefined ? '' : ` (edit, ${args.referenceImages.length} images)`}: ${truncate(args.prompt)}`, }), // The web UI has no image surface on tool cards and flattens result blocks // to text/JSON, so the completed card shows the text summary only; the diff --git a/src/tools/video-generate.ts b/src/tools/video-generate.ts index 4dd9e90..f2d2e63 100644 --- a/src/tools/video-generate.ts +++ b/src/tools/video-generate.ts @@ -48,6 +48,11 @@ export interface VideoGenerateToolOptions { pollIntervalMs?: number /** Overall deadline from submit to completion. */ maxWaitMs?: number + /** + * Registered tool name. Defaults to `video_generate`; the plugin falls back + * to an alias when another plugin already owns that name (issue #76). + */ + name?: string } /** The wire request body for one generation call. */ @@ -194,8 +199,9 @@ interface VideoGenerateValue { export function createVideoGenerateTool(options: VideoGenerateToolOptions): ToolDefinition { const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS + const toolName = options.name ?? 'video_generate' return defineTool({ - name: 'video_generate', + name: toolName, description: `Generate a short video (1-15 seconds) with the grok subscription (${VIDEO_GENERATE_MODEL}) ` + 'and save it as an MP4 file. Generation is asynchronous and may take a minute or more; ' + 'the tool waits for completion and returns the saved file path. ' @@ -247,7 +253,7 @@ export function createVideoGenerateTool(options: VideoGenerateToolOptions): Tool }, presentCall: args => ({ card: 'generic', - title: `video_generate: ${truncate(args.prompt)}`, + title: `${toolName}: ${truncate(args.prompt)}`, }), async execute(args, exec) { const body = buildVideoGenerateBody(args) diff --git a/src/tools/x-search.ts b/src/tools/x-search.ts index c30dcdc..b409c6b 100644 --- a/src/tools/x-search.ts +++ b/src/tools/x-search.ts @@ -26,6 +26,12 @@ export interface XSearchToolOptions { tokens: AccountTokenManager /** Fetch implementation (injectable for tests). */ fetchFn?: FetchFn + /** + * Registered tool name. Defaults to `x_search`; when another plugin already + * owns that name (issue #76), the plugin mounts this tool under an alias + * instead so both search tools stay available. + */ + name?: string } /** Normalized, validated arguments of one search call. */ @@ -131,8 +137,9 @@ function truncate(text: string, max = 60): string { * @returns the tool to register on `ctx.tools`. */ export function createXSearchTool(options: XSearchToolOptions): ToolDefinition { + const toolName = options.name ?? 'x_search' return defineTool({ - name: 'x_search', + name: toolName, description: "Search X (Twitter) posts, profiles, and threads using the grok subscription's hosted xAI x_search. " + 'Use this for current discussion, reactions, or claims on X rather than general web pages.', parameters: { @@ -177,7 +184,7 @@ export function createXSearchTool(options: XSearchToolOptions): ToolDefinition { }, presentCall: args => ({ card: 'generic', - title: `x_search: ${truncate(args.query)}`, + title: `${toolName}: ${truncate(args.query)}`, kind: 'search', }), presentResult: (_args, result) => { diff --git a/test/detect-cli.spec.ts b/test/detect-cli.spec.ts index a6668fa..15c7660 100644 --- a/test/detect-cli.spec.ts +++ b/test/detect-cli.spec.ts @@ -6,6 +6,9 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { tmpdir } from 'node:os' import { detectClaudeVersion, CLAUDE_CLI_FALLBACK_VERSION, @@ -37,6 +40,64 @@ test('detectClaudeVersion returns the fallback when claude is not in PATH', () = } }) +/** + * Regression: on Windows the CLI is `claude.cmd`, which `execFileSync` cannot + * spawn directly — `ENOENT` for the extensionless name, `EINVAL` for the + * `.cmd`. Detection then fell through to the fallback constant on every run, + * so the user-agent advertised a stale version regardless of what was + * installed, and Anthropic rejected models gated on a newer CLI. + * + * The assertion is deliberately indirect: it cannot require a real CLI in CI, + * so it pins the property that actually broke — when a working `claude` + * resolves, detection must report *its* version rather than the constant. + */ +test('detectClaudeVersion reads a resolvable CLI rather than the fallback', (t) => { + const bin = join(mkdtempSync(join(tmpdir(), 'claude-probe-')), process.platform === 'win32' ? 'claude.cmd' : 'claude') + const reported = '9.9.9' + writeFileSync( + bin, + process.platform === 'win32' ? `@echo off\r\necho ${reported} (Claude Code)\r\n` : `#!/bin/sh\necho "${reported} (Claude Code)"\n`, + { mode: 0o755 }, + ) + + const original = process.env.PATH + try { + process.env.PATH = dirname(bin) + const version = detectClaudeVersion() + if (version === CLAUDE_CLI_FALLBACK_VERSION) { + assert.fail( + 'detection fell back to the hardcoded constant even though a working ' + + `\`claude\` was on PATH (expected ${reported}). On Windows this is the ` + + 'execFileSync ENOENT/EINVAL batch-shim failure.', + ) + } + assert.equal(version, reported) + } finally { + process.env.PATH = original + rmSync(dirname(bin), { recursive: true, force: true }) + } +}) + +/** + * The fallback is only a safety net, but it is still sent verbatim whenever + * detection fails (CLI absent, sandboxed spawn). Letting it drift below the + * floor Anthropic enforces turns that net into a guaranteed HTTP 400. + */ +test('detectClaudeVersion fallback is new enough for currently gated models', () => { + const MINIMUM = '2.1.251' + const ordinal = (v: string) => v.split('.').map(Number) + const [major, minor, patch] = ordinal(CLAUDE_CLI_FALLBACK_VERSION) + const [minMajor, minMinor, minPatch] = ordinal(MINIMUM) + const isAtLeast = + major > minMajor || + (major === minMajor && (minor > minMinor || (minor === minMinor && patch >= minPatch))) + assert.ok( + isAtLeast, + `fallback ${CLAUDE_CLI_FALLBACK_VERSION} is below the ${MINIMUM} floor required by ` + + 'current models; requests made while detection is failing would be rejected', + ) +}) + // --------------------------------------------------------------------------- // CLAUDE_BETA_FALLBACK // --------------------------------------------------------------------------- diff --git a/test/index.ts b/test/index.ts index 3d05876..e1e0f85 100644 --- a/test/index.ts +++ b/test/index.ts @@ -13,6 +13,8 @@ import './copilot.spec.js' import './tools.spec.js' import './image-pool.spec.js' import './rpc.spec.js' +import './rpc-fetch.spec.js' +import './subscriptions-rpc.spec.js' import './usage.spec.js' import './rate-limit.spec.js' import './detect-cli.spec.js' @@ -31,3 +33,4 @@ import './model-defaults-rpc.spec.js' import './model-defaults-view.spec.js' import './provider-settings.spec.js' import './provider-settings-rpc.spec.js' +import './tool-collision.spec.js' diff --git a/test/login.spec.ts b/test/login.spec.ts index ea93bae..2535e87 100644 --- a/test/login.spec.ts +++ b/test/login.spec.ts @@ -186,6 +186,22 @@ test('readClaudeCodeCredentials returns undefined for incomplete credentials', n }) }) +test('readClaudeCodeCredentials returns undefined for EMPTY-string credentials', needsFileStore, async () => { + // A corrupted Keychain item (observed in the wild after a Claude Code + // logout) holds the right keys with empty strings — importing it used to + // poison the auth store and blind every provider's status page. + const blob = JSON.stringify({ + claudeAiOauth: { + accessToken: '', refreshToken: '', expiresAt: 0, + scopes: ['user:profile'], subscriptionType: 'pro', + }, + }) + await withEnv('CLAUDE_CONFIG_DIR', credentialsDir('claude-empty-str-', blob), () => { + assert.equal(readClaudeCodeCredentials(), undefined, 'empty tokens = undefined') + return Promise.resolve() + }) +}) + test('readClaudeCodeCredentials reads bare fields (no claudeAiOauth wrapper)', needsFileStore, async () => { const blob = JSON.stringify({ accessToken: 'bare-at', refreshToken: 'bare-rt', expiresAt: Date.now() + 3600_000, diff --git a/test/rpc-fetch.spec.ts b/test/rpc-fetch.spec.ts new file mode 100644 index 0000000..5188ba5 --- /dev/null +++ b/test/rpc-fetch.spec.ts @@ -0,0 +1,90 @@ +/** + * Unit tests for the `/api/subscriptions-auth` Fetch route codec: the + * client-request / server-response envelope `rpc.call` speaks, the inner + * `{ endpoint, payload }` unwrap, and the rejections the host's own channel + * bridge applies (non-JSON content type, unparsable body, bad envelope). + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import type { RpcResult } from '../src/compat.js' +import { SUBSCRIPTIONS_AUTH_ROUTE, subscriptionsAuthFetch } from '../src/auth/rpc-fetch.js' + +type Seen = { endpoint: string; payload: unknown; signal: AbortSignal } + +function route(result: RpcResult = { ok: true, value: 'v' }) { + const seen: Seen[] = [] + const fetch = subscriptionsAuthFetch(async (endpoint, payload, signal) => { + seen.push({ endpoint, payload, signal }) + return result + }) + return { fetch, seen } +} + +function post(body: unknown, contentType = 'application/json; charset=utf-8', signal?: AbortSignal): Request { + return new Request(`http://dsh.internal${SUBSCRIPTIONS_AUTH_ROUTE}`, { + method: 'POST', + headers: { 'content-type': contentType }, + body: typeof body === 'string' ? body : JSON.stringify(body), + ...signal === undefined ? {} : { signal }, + }) +} + +const envelope = (payload: unknown, method = 'subscriptions-auth') => + ({ type: 'client-request', rpcId: 'r1', method, payload }) + +test('route path sits below the shared /api channel', () => { + assert.equal(SUBSCRIPTIONS_AUTH_ROUTE, '/api/subscriptions-auth') +}) + +test('unwraps the endpoint and answers with the server-response envelope', async () => { + const { fetch, seen } = route({ ok: true, value: { providers: {} } }) + const controller = new AbortController() + const response = await fetch(post(envelope({ endpoint: 'status', payload: { a: 1 } }), undefined, controller.signal)) + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { + type: 'server-response', rpcId: 'r1', result: { ok: true, value: { providers: {} } }, + }) + assert.equal(seen.length, 1) + assert.equal(seen[0].endpoint, 'status') + assert.deepEqual(seen[0].payload, { a: 1 }) + controller.abort() + assert.equal(seen[0].signal.aborted, true, 'the request signal reaches the handler') +}) + +test('business failures pass through unchanged', async () => { + const failure: RpcResult = { ok: false, error: { code: 'internal', message: 'boom', details: {} } } + const { fetch } = route(failure) + const body = await (await fetch(post(envelope({ endpoint: 'usage', payload: {} })))).json() + assert.deepEqual(body, { type: 'server-response', rpcId: 'r1', result: failure }) +}) + +test('transport-level rejections mirror the host channel bridge', async () => { + const { fetch, seen } = route() + assert.equal((await fetch(post(envelope({ endpoint: 'status' }), 'text/plain'))).status, 415) + assert.equal((await fetch(post('{not json'))).status, 400) + assert.equal(seen.length, 0) +}) + +test('malformed envelopes and inner payloads become bad-request results', async () => { + const { fetch, seen } = route() + const cases = [ + [{ type: 'client-request', method: 'subscriptions-auth', payload: {} }, 'invalid-request', /client-request/], + [envelope({ endpoint: 'status' }, 'other'), 'r1', /does not match/], + [envelope('nope'), 'r1', /endpoint/], + [envelope({ endpoint: '' }), 'r1', /endpoint/], + [envelope({ endpoint: 7 }), 'r1', /endpoint/], + ] as const + for (const [body, rpcId, pattern] of cases) { + const response = await fetch(post(body)) + assert.equal(response.status, 200, JSON.stringify(body)) + const json = await response.json() as { rpcId: string; result: RpcResult } + assert.equal(json.rpcId, rpcId) + assert.equal(json.result.ok, false) + if (!json.result.ok) { + assert.equal(json.result.error.code, 'bad-request') + assert.match(json.result.error.message, pattern) + } + } + assert.equal(seen.length, 0) +}) diff --git a/test/rpc.spec.ts b/test/rpc.spec.ts index ad5f21c..314c4cd 100644 --- a/test/rpc.spec.ts +++ b/test/rpc.spec.ts @@ -2,7 +2,8 @@ * Unit tests for the `/subscriptions-auth` `image` endpoint: payload * validation, the base64 round trip through a fake attachment store, and the * no-service / read-failure error results. Drives the real plugin wiring with - * a fake host connection; DSH_HOME is redirected to a temp dir. + * a fake host connection; DSH_HOME is redirected to a temp dir. Also covers + * the `status` endpoint degrading per provider when one store entry is corrupt. */ import { test } from 'node:test' @@ -11,7 +12,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' -import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import type { ConnectionFetchRoute, ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { RpcResult } from '../src/compat.js' process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'router-rpc-test-')) @@ -179,3 +180,59 @@ test('speed endpoints: per-session tier round trip and payload validation', asyn } } }) + +test('fetch-route hosts: endpoints ride /api/subscriptions-auth and the legacy channel stays unmounted', async () => { + const routes: ConnectionFetchRoute[] = [] + let channels = 0 + const ctx = new Context() + ctx.provide('llm', { registerAdapter: () => Object.assign(() => {}, { replace: () => {} }) }) + ctx.provide('connection', { + rpc: { handle: () => { channels++; return () => Promise.resolve() } }, + fetch: { register: (route: ConnectionFetchRoute) => { routes.push(route); return () => Promise.resolve() } }, + }) + ctx.plugin(plugin, { providers: ['codex'] }) + await new Promise(resolve => setTimeout(resolve, 50)) + assert.equal(channels, 0, 'rpc.handle is never called when the Fetch registry exists (it throws on 0.1.5)') + assert.equal(routes.length, 1) + const [route] = routes + assert.deepEqual( + [route.path, route.methods, (route as { requestBody?: string }).requestBody], + ['/api/subscriptions-auth', ['POST'], 'buffered'], + ) + + const post = async (inner: unknown) => { + const response = await route.fetch(new Request('http://dsh.internal/api/subscriptions-auth', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'subscriptions-auth', payload: inner }), + })) + return (await response.json() as { result: RpcResult }).result + } + assert.deepEqual(await post({ endpoint: 'setSpeed', payload: { sessionId: 's1', tier: 'fast' } }), { ok: true, value: { ok: true } }) + assert.deepEqual(await post({ endpoint: 'speed', payload: { sessionId: 's1' } }), { ok: true, value: { tier: 'fast', fastModels: [] } }) + const unknown = await post({ endpoint: 'nope', payload: {} }) + assert.equal(unknown.ok, false) + if (!unknown.ok) assert.equal(unknown.error.code, 'bad-request') +}) + +test('status endpoint: one corrupt provider entry degrades alone, others still report', async () => { + // The exact corruption seen in the wild: empty tokens under a claude key. + // Before the fix this rejected the WHOLE status call and the UI sat on + // "Checking…" forever with every provider blind. + const { mkdirSync: mkDir } = await import('node:fs') + const home = process.env.DSH_HOME as string + mkDir(join(home, 'plugins', 'subscriptions'), { recursive: true }) + writeFileSync(join(home, 'plugins', 'subscriptions', 'auth.json'), JSON.stringify({ + codex: { default: 'acct-1', accounts: { 'acct-1': { + accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3600_000, accountId: 'acct-1', + } } }, + claude: { default: 'corrupt', accounts: { corrupt: { accessToken: '', refreshToken: '', expiresAt: 0 } } }, + }), { mode: 0o600 }) + const handler = await mount() + const result = await handler('status', {}, new AbortController().signal) + assert.ok(result.ok, 'the status call itself must succeed') + if (!result.ok) return + const providers = (result.value as { providers: Record }).providers + assert.equal(providers.codex.accounts.length, 1, 'codex still reports its account') + assert.equal(providers.claude.accounts.length, 0, 'the corrupt claude entry is skipped') +}) diff --git a/test/store.spec.ts b/test/store.spec.ts index 951510a..70595fa 100644 --- a/test/store.spec.ts +++ b/test/store.spec.ts @@ -153,3 +153,46 @@ test('a single-account store migrates on read, preserving every field', async () const onDisk = JSON.parse(readFileSync(path, 'utf8')) as Record assert.ok(onDisk.codex?.accounts !== undefined, 'the file now uses the accounts shape') }) + +test('saveAccountSession rejects an empty-token session before it can poison the store', async () => { + const path = storePath() + await saveAccountSession('codex', 'acct-1', CODEX, path) + const corrupt = { ...CLAUDE, accessToken: '', refreshToken: '', expiresAt: 0 } + await saveAccountSession('claude', 'corrupt', corrupt, path).then( + () => assert.fail('saving an empty-token session must throw'), + (error: unknown) => assert.match(String(error), /missing accessToken\/refreshToken\/expiresAt/), + ) + // The store survives intact: the earlier valid account is still readable. + assert.equal((await getAccountSession('codex', undefined, path))?.accessToken, CODEX.accessToken) +}) + +test('one corrupt provider entry does not blind the other providers', async () => { + const path = storePath() + // The exact corruption seen in the wild: empty tokens under a claude key. + writeFileSync(path, JSON.stringify({ + codex: { default: 'acct-1', accounts: { 'acct-1': CODEX } }, + claude: { default: 'corrupt', accounts: { corrupt: { accessToken: '', refreshToken: '', expiresAt: 0 } } }, + }), { mode: 0o600 }) + // Codex keeps its account; the corrupt claude entry is skipped, not fatal. + assert.equal((await listAccounts('codex', path)).length, 1) + assert.equal((await listAccounts('claude', path)).length, 0) + // …and the next write persists the store without the corrupt entry. + await saveAccountSession('codex', 'acct-1', CODEX, path) + const onDisk = JSON.parse(readFileSync(path, 'utf8')) as Record + assert.equal(onDisk.claude, undefined, 'the corrupt entry is dropped on the next write') +}) + +test('a valid account survives alongside a corrupt sibling of the same provider', async () => { + const path = storePath() + writeFileSync(path, JSON.stringify({ + codex: { + default: 'acct-1', + accounts: { + 'acct-1': CODEX, + corrupt: { accessToken: '', refreshToken: '', expiresAt: 0 }, + }, + }, + }), { mode: 0o600 }) + const entries = await listAccounts('codex', path) + assert.deepEqual(entries.map((entry) => entry.key), ['acct-1'], 'only the valid account is listed') +}) diff --git a/test/subscriptions-rpc.spec.ts b/test/subscriptions-rpc.spec.ts new file mode 100644 index 0000000..4d8ddd0 --- /dev/null +++ b/test/subscriptions-rpc.spec.ts @@ -0,0 +1,45 @@ +/** + * Unit tests for the browser-side transport choice: on hosts with exact Fetch + * routes (dsh 0.1.2-alpha.1+, recognised by the absent rc.2 `.api` face) the + * `/subscriptions-auth` calls ride `/api/subscriptions-auth`; rc.2 keeps the + * legacy channel; other channels are never rewritten. + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { routeSubscriptionsAuth } from '../src/client/subscriptions-rpc.js' + +type Call = [channel: string, endpoint: string, payload: unknown, signal: AbortSignal | undefined] + +function fakeConnection(extra: object = {}) { + const calls: Call[] = [] + const rpc = { + call: (channel: string, endpoint: string, payload: unknown, signal?: AbortSignal) => { + calls.push([channel, endpoint, payload, signal]) + return Promise.resolve({ ok: true as const, value: 'v' }) + }, + } + const connection = { rpc, isLoopback: true, ...extra } as unknown as ConnectionHandle + return { connection, calls } +} + +test('fetch-route hosts: subscriptions-auth calls ride the /api route with the endpoint in the payload', async () => { + const { connection, calls } = fakeConnection({ generation: {} }) + const routed = routeSubscriptionsAuth(connection) + const signal = new AbortController().signal + assert.deepEqual(await routed.rpc.call('/subscriptions-auth', 'usage', { provider: 'codex' }, signal), { ok: true, value: 'v' }) + assert.deepEqual(calls, [['/api', 'subscriptions-auth', { endpoint: 'usage', payload: { provider: 'codex' } }, signal]]) +}) + +test('fetch-route hosts: other channels pass through untouched', async () => { + const { connection, calls } = fakeConnection() + await routeSubscriptionsAuth(connection).rpc.call('/api', 'goals/create', { x: 1 }) + assert.deepEqual(calls, [['/api', 'goals/create', { x: 1 }, undefined]]) +}) + +test('rc.2 hosts (legacy .api face): calls keep the legacy channel', async () => { + const { connection, calls } = fakeConnection({ api: {} }) + await routeSubscriptionsAuth(connection).rpc.call('/subscriptions-auth', 'status', {}) + assert.deepEqual(calls, [['/subscriptions-auth', 'status', {}, undefined]]) +}) diff --git a/test/tool-collision.spec.ts b/test/tool-collision.spec.ts new file mode 100644 index 0000000..3ef9cc2 --- /dev/null +++ b/test/tool-collision.spec.ts @@ -0,0 +1,106 @@ +/** + * Tool-name collision handling (issue #76): another plugin owning `x_search` + * (e.g. @liustack/modsearch) used to make the duplicate insert throw out of + * the plugin's apply, which took the providers, the tools, and the auth + * channel down with it. The plugin now mounts the tool under an alias + * instead, and degrades to a skip — never a crash — when even the alias is + * taken. Drives the real plugin wiring with a fake tools registry whose + * `get`/`register` mirror the ToolRuntime face; DSH_HOME is redirected to a + * temp dir with a logged-in grok store so the tool trio would register. + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' + +process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'tool-collision-test-')) + +// Imports after the env override so the store path resolves under the temp home. +const plugin = await import('../src/index.js') + +/** + * A tools registry that already owns the given names (the modsearch stand-in). + * `registered` is the full name set (seeded ownership plus successful + * registrations); `attempted` is every `register` call, so a skipped + * candidate — probe-short-circuited or thrown — is distinguishable from a + * name that was owned all along. + */ +function fakeTools(owned: readonly string[]): { + registered: () => string[] + attempted: () => string[] + service: object +} { + const names = new Set(owned) + const attempted: string[] = [] + return { + registered: () => [...names], + attempted: () => [...attempted], + service: { + get: (name: string) => names.has(name) ? { name } : undefined, + register: (definition: { name: string }) => { + attempted.push(definition.name) + if (names.has(definition.name)) { + throw new Error(`tool "${definition.name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`) + } + names.add(definition.name) + return () => {} + }, + }, + } +} + +/** Mount the plugin with a grok session and a tools registry owning `owned`. */ +async function mountTools(owned: readonly string[]): Promise<{ + registered: string[] + attempted: string[] +}> { + const home = process.env.DSH_HOME as string + mkdirSync(join(home, 'plugins', 'subscriptions'), { recursive: true }) + writeFileSync(join(home, 'plugins', 'subscriptions', 'auth.json'), JSON.stringify({ + grok: { default: 'acct-1', accounts: { 'acct-1': { + accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000, tokenEndpoint: 'https://auth.x.ai/token', + } } }, + }), { mode: 0o600 }) + const fake = fakeTools(owned) + const ctx = new Context() + ctx.provide('llm', { registerAdapter: () => Object.assign(() => {}, { replace: () => {} }) }) + ctx.provide('tools', fake.service) + ctx.plugin(plugin, { providers: ['grok'], pool: { enabled: false } }) + // The tools inject callback settles on a later tick. + await new Promise(resolve => setTimeout(resolve, 50)) + const out = { registered: fake.registered(), attempted: fake.attempted() } + rmSync(join(home, 'plugins'), { recursive: true, force: true }) + return out +} + +test('a foreign x_search owner no longer aborts the apply; the tool mounts as grok_x_search', async () => { + const { registered, attempted } = await mountTools(['x_search']) + assert.ok(registered.includes('grok_x_search'), `aliased registration missing in ${JSON.stringify(registered)}`) + assert.ok(registered.includes('video_generate'), 'video_generate still registers') + assert.equal(attempted.filter(name => name === 'x_search').length, 0, + 'the canonical name is never re-attempted; it stays with the other plugin') +}) + +test('without a collision the canonical names register', async () => { + const { registered, attempted } = await mountTools([]) + for (const expected of ['x_search', 'video_generate']) { + assert.ok(registered.includes(expected), `${expected} missing in ${JSON.stringify(registered)}`) + assert.ok(attempted.includes(expected), `${expected} registered under its canonical name`) + } + assert.ok(!registered.includes('grok_x_search')) +}) + +test('an alias collision degrades to a skip and the plugin still applies', async () => { + // Everything grok would register is owned, canonical and alias alike; the + // image tool (grok is its fallback provider here) still mounts normally. + const { registered, attempted } = await mountTools( + ['x_search', 'grok_x_search', 'video_generate', 'grok_video_generate']) + for (const skipped of ['x_search', 'grok_x_search', 'video_generate', 'grok_video_generate']) { + assert.equal(attempted.filter(name => name === skipped).length, 0, + `${skipped} is probed away, never attempted`) + } + assert.ok(registered.includes('image_generate'), 'image_generate still registers') +}) diff --git a/test/usage.spec.ts b/test/usage.spec.ts index 674d64e..0fef6c0 100644 --- a/test/usage.spec.ts +++ b/test/usage.spec.ts @@ -276,6 +276,20 @@ test('fetchGrokUsage tolerates a null config and non-2xx responses', async () => await assert.rejects(fetchGrokUsage(grokSession, failing), /grok billing/) }) +test('fetchGrokUsage extracts reset window when creditUsagePercent is absent but currentPeriod is present', async () => { + const { fetchFn } = fakeFetch({ + config: { + currentPeriod: { type: 'USAGE_PERIOD_TYPE_WEEKLY', end: '2026-09-10T12:00:00Z' }, + }, + }) + const usage = await fetchGrokUsage(grokSession, fetchFn) + assert.ok(usage.windows) + assert.equal(usage.windows.length, 1) + assert.equal(usage.windows[0]?.kind, 'weekly') + assert.equal(usage.windows[0]?.usedPercent, 0) + assert.equal(usage.windows[0]?.resetsAt, Date.parse('2026-09-10T12:00:00Z')) +}) + /** Mount the plugin with fake llm/connection; return the RPC handler. */ async function mount(): Promise { let handler: ConnectionRpcHandler | undefined