From 91cd8e8449d66f3946e10ee40a47d78395113e7c Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 1/6] fix(transport): serve settings endpoints as an /api Fetch route on dsh 0.1.5+ dsh 0.1.5 removed the webServer inject from client-connection, so connection.rpc.handle throws 'cannot get property webServer without inject' and every /subscriptions-auth POST falls through to the SPA with HTTP 405 (issues #77, #80). Register the endpoints as the exact /api/subscriptions-auth Fetch route (present since 0.1.2-alpha.1), falling back to the legacy channel on 0.1.1-rc.2, and reroute the browser half's calls by the same detection. Applied from PR #82 by Sunyata-Anatta. --- README.md | 2 +- README.zh.md | 2 +- src/auth/rpc-fetch.ts | 77 ++++++++++++++++++++++++++++ src/auth/rpc.ts | 68 ++++++++++++++++++------- src/client/SpeedSelect.tsx | 4 +- src/client/index.ts | 6 ++- src/client/subscriptions-rpc.ts | 25 +++++++++ src/index.ts | 4 +- test/index.ts | 2 + test/rpc-fetch.spec.ts | 90 +++++++++++++++++++++++++++++++++ test/rpc.spec.ts | 36 ++++++++++++- test/subscriptions-rpc.spec.ts | 45 +++++++++++++++++ 12 files changed, 335 insertions(+), 26 deletions(-) create mode 100644 src/auth/rpc-fetch.ts create mode 100644 test/rpc-fetch.spec.ts create mode 100644 test/subscriptions-rpc.spec.ts 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/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..6320b13 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 } } @@ -490,7 +509,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 +526,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/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/index.ts b/src/client/index.ts index f1cf1ce..a0be010 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({ 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..beb2229 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 */ diff --git a/test/index.ts b/test/index.ts index 3d05876..fe9d762 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' 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..0fb7ea8 100644 --- a/test/rpc.spec.ts +++ b/test/rpc.spec.ts @@ -11,7 +11,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 +179,37 @@ 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') +}) 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]]) +}) From 2ec13047ef41b4b2efbaa772dfd6caf0db7d6e04 Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 2/6] fix(client): follow the 0.1.5 command description resolver dsh 0.1.5-alpha.1 turned CommandContribution.description into a thunk evaluated per candidate pass; passing the plain string threw inside the registry and took the whole '/' command surface down (issue #79). Older lines render the function as empty row copy instead of crashing. Applied from PR #75 by kaijia323. --- package.json | 2 +- pnpm-lock.yaml | 14 +++++++++----- src/client/index.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index b635b69..ad7b10b 100644 --- a/package.json +++ b/package.json @@ -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/client/index.ts b/src/client/index.ts index a0be010..f182d6c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -152,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', From 69b88950d9864b5fd4f2528498165037a0b1667b Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 3/6] fix(grok): parse billing windows when creditUsagePercent is absent Some tiers answer the billing endpoint with only currentPeriod; the card now still shows the window and its reset time (used percent falls back to 0) instead of hiding the usage section. Applied from PR #64 by Frost2026. fix(claude): detect the CLI version on Windows instead of pinning the stale fallback npm installs Claude Code as claude.cmd there, which execFileSync cannot spawn directly since the CVE-2024-27980 fix; probe through cmd.exe with shell:true and raise the probe timeout to 10s. Applied from PR #74 by w3a11y. --- src/providers/claude.ts | 42 ++++++++++++++++++++++++---- src/providers/grok.ts | 7 +++-- test/detect-cli.spec.ts | 61 +++++++++++++++++++++++++++++++++++++++++ test/usage.spec.ts | 14 ++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) 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/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/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 From 97c2830a95affe8446d25ea75c99a14333c6fa97 Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 4/6] fix(auth): one corrupt credential entry can no longer blind every provider Empty tokens (seen from a corrupted Keychain item after a Claude Code logout) now fail the import gate; store entries without usable tokens are skipped with a warning instead of rejecting the whole load, and the status endpoint degrades one provider's failure to its own detail instead of failing every card. The write path validates sessions with the same strictness. Applied from PR #59 by CHENHUI-X (test conflict with the #82 fetch route test resolved by keeping both). --- src/auth/claude-code-creds.ts | 7 +++- src/auth/rpc.ts | 9 ++++- src/auth/store.ts | 56 ++++++++++++++++++++++++----- src/client/SubscriptionsSection.tsx | 11 ++++-- test/login.spec.ts | 16 +++++++++ test/rpc.spec.ts | 25 ++++++++++++- test/store.spec.ts | 43 ++++++++++++++++++++++ 7 files changed, 152 insertions(+), 15 deletions(-) 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.ts b/src/auth/rpc.ts index 6320b13..eea863a 100644 --- a/src/auth/rpc.ts +++ b/src/auth/rpc.ts @@ -443,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) }) } 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/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/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.spec.ts b/test/rpc.spec.ts index 0fb7ea8..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' @@ -213,3 +214,25 @@ test('fetch-route hosts: endpoints ride /api/subscriptions-auth and the legacy c 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') +}) From f5ff53afcbbc2564a81aad547baa8d75c3cac35b Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 5/6] fix(tools): a foreign tool-name owner no longer aborts the whole apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Another plugin registering the same tool name (e.g. @liustack/modsearch owning x_search) made the duplicate insert throw out of the plugin's apply, taking the providers and the auth channel down with it (issue #76). Each tool now probes the registry and falls back to a namespaced alias — grok_x_search / grok_video_generate / subscriptions_image_generate — and degrades to a skip with a warning when even the alias is taken; the Settings-page switches keep keying on the stable canonical names. --- src/index.ts | 84 +++++++++++++++++++++++----- src/tools/image-generate.ts | 10 +++- src/tools/video-generate.ts | 10 +++- src/tools/x-search.ts | 11 +++- test/index.ts | 1 + test/tool-collision.spec.ts | 106 ++++++++++++++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 19 deletions(-) create mode 100644 test/tool-collision.spec.ts diff --git a/src/index.ts b/src/index.ts index beb2229..cbc0189 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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/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/index.ts b/test/index.ts index fe9d762..e1e0f85 100644 --- a/test/index.ts +++ b/test/index.ts @@ -33,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/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') +}) From acf6acd01110c1a5c7096bcddb3a6894952f0c1d Mon Sep 17 00:00:00 2001 From: krfalcon <3618977+krfalcon@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:53:29 +0800 Subject: [PATCH 6/6] chore: release v0.8.1 with dsh 0.1.5 support Peer ranges admit the 0.1.3 and 0.1.5 lines, whose transports the feature-detecting rpc registration already serves. --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index ad7b10b..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": {