Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 2fb7619a18 feat(vscode): augment legacy models from models.dev 2026-06-27 21:39:39 -07:00
Saoud Rizwan f81afb51b5 chore(vscode): bump to 4.0.1 for legacy rollback release 2026-06-27 19:09:05 -07:00
John Choi 864419d8b0 Fix ClinePass onboarding model grouping (#11768) 2026-06-23 17:38:30 -07:00
Tomás Barreiro 218db38544 Add to the status bar [ENG-2216] (#11766)
* Add  to the status bar

* Update tests
2026-06-24 01:24:46 +02:00
Bee a3b3295461 fix(llms): includes session id for OpenRouter caching (#11744)
* Improve OpenRouter sticky session routing

OpenRouter prompt caching was getting weaker cache hit rates because requests did not include a stable session_id. Without that identifier, OpenRouter can route consecutive turns away from the same upstream cache context even when cache_control is present, causing more fresh input billing.

Propagate explicit runtime sessionId metadata from agents into model requests without synthesizing fallback session or conversation ids. Add provider metadata for sticky sessions so OpenRouter maps metadata.sessionId to the JSON body session_id field, while keeping the mechanism extensible for header-based providers.

Apply sticky-session metadata in the AI SDK provider fetch wrapper, preserving explicit wire values when callers already set them. Move trimNonEmpty and omitUndefinedValues into @cline/shared for reuse, and cover the JSON-body, header, and no-fallback paths with tests.

* Tighten sticky session fetch body handling

Treat init.body null as an explicit no-body override so the sticky-session wrapper does not inspect or parse a Request body that the caller has intentionally overridden.

Model fetch body text by source, which removes the unreachable JSON-body injection branch and makes the Request rewrite path explicit. Also type the provider fetch mocks so sticky-session assertions compile cleanly in editors.

* merges model-handle default metadata with per-request metadata
2026-06-23 12:07:17 -07:00
MaxandMax Paulus 🥪 d09270940f fix(hub): validate dashboard websocket host and origin (#11724)
* fix(hub): validate browser websocket origins

* test(hub): cover websocket host and origin gates

* fix(hub): allow explicit bind host for dashboard socket

* fix(hub): default protect browser routes

* fix(hub): normalize default port origins

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 10:14:25 -07:00
Saoud Rizwan 23c9bf1c05 fix: prevent 'ERROR: EMPTY CONTENT' message when error occurs (#11726)
* fix: prevent empty assistant history replay

* refactor: move tool call filtering after empty guard
2026-06-23 09:33:10 -07:00
Bee 52531d935a refactor: add connector configure path and share catalog to shared pkg (#11730)
* feat(hub): add connector configure path and share catalog via @cline/shared

Add connector.channels/configure/delete_config Hub commands that persist
connector settings to disk without starting connector processes or calling
provider auth APIs. This means settings (including tokens) are saved as-is
without verification; callers are responsible for supplying valid values.

Shared catalog and platform definitions:
- Move ConnectorCatalogEntry, CONNECTOR_CATALOG, listConnectorCatalog, and
  all ConnectorPlatformDef/FieldDef/SecurityDef types + CONNECTOR_PLATFORMS
  into sdk/packages/shared/src/connectors/platforms.ts
- Export everything from @cline/shared index so CLI and Hub use the same
  definitions without duplication
- Reduce apps/cli/src/connectors/catalog.ts and
  apps/cli/src/wizards/connect/platforms.ts to thin re-export shims that
  preserve existing CLI import paths

New Hub connector handlers (sdk/packages/core):
- connector-handlers.ts: handles connector.channels (list available/active/
  configured), connector.configure (validate fields and write settings.json
  under ~/.cline/data/connectors/), connector.delete_config (remove entry
  and clean up empty file)
- Settings are stored as ConnectorSettingsFile (version 1) at
  ~/.cline/data/connectors/settings.json; reads are lenient/defensive
- Wire handlers into hub-server-transport.ts dispatch switch
- connector-handlers.test.ts: unit tests for configure, channels, and
  delete_config covering field validation, conditional fields, security
  constraints, and settings round-trips

Hub WebSocket auth helpers (hub-websocket-server.ts):
- Extract isLocalHubHostName / isLocalHubOrigin as named, tested exports
- Allow unauthenticated WebSocket upgrades from local origins (localhost,
  127.0.0.1, ::1) so the Hub UI can connect without an auth token
- hub-websocket-server.test.ts: extend tests to cover new local-origin logic

Add connector.channels, connector.configure, connector.delete_config to
HubCommandName union in sdk/packages/shared/src/hub.ts

* apply feedback

* export connector settings json path

* apply feedback from robin
2026-06-22 22:25:49 -07:00
Tomás BarreiroandSaoud Rizwan 19d4248381 Add Organization error messages to the Cline CLI (#11737)
* Add Organization error messages to the Cline CLI

* Fix tests

* Fix tests

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-22 17:18:29 -07:00
Tomás Barreiro b2b29678a2 Add a button to unselect an org on ClinePass error (#11738)
* Add a button to unselect an org on ClinePass error

* Fix comments
2026-06-23 01:47:15 +02:00
Bee ad30714608 fix(hub-ui): Use development mode conditionally for connector (#11728)
* fix(hub-ui): Use development mode conditionally for connector

Refactor connector CLI launch logic to conditionally apply Bun-specific
flags (`--conditions=development`) only when running under Bun or Node
runtimes with access to the source entrypoint. When running from a
compiled binary (e.g., packaged Cline app), use the execPath directly
without Bun flags.

- Extract `buildCliConnectCommand` function to encapsulate launcher
  and args resolution logic based on runtime detection
- Use `withResolvedClineBuildEnv` for environment variable setup
- Export `__test__` object to enable unit testing of internal logic
- Add tests covering Bun source entrypoint and compiled binary cases

* address feedback
2026-06-22 16:41:47 -07:00
Bee 9eebc44f24 chore: Optimize Cline Hub webview bundle (#11731)
* Optimize Cline Hub webview bundle

Issue:

The Cline Hub webview build emitted Vite's large chunk warning. The main entry bundle was over 2 MB minified, and the build output included many Shiki language/theme chunks plus eagerly bundled Streamdown math/Mermaid support that the hub did not need on startup.

Changes:

- Split heavyweight Cline Hub views behind React.lazy/Suspense so settings, customization, and chat code do not all land in the initial app shell.

- Replace root Shiki highlighter usage with shiki/core and lazily loaded, explicitly supported languages/themes.

- Route Streamdown through a local HubStreamdown wrapper that uses the local CodeBlock renderer and keeps Mermaid as a lazy diagram plugin.

- Remove unused Streamdown math/code/Mermaid packages, add direct lazy Mermaid support, and add targeted Rolldown chunk groups for Mermaid parser/layout/markup assets.

Before/after bundle measurements:

| Metric | Before | After |

| --- | ---: | ---: |

| Total generated JS | 24,356 KiB | 5,800 KiB |

| Total generated JS gzip | 4,805.5 KiB | 1,434.6 KiB |

| Main entry chunk | 2,044.02 kB | 373.88 kB |

| Main entry gzip | 617.25 kB | 117.83 kB |

Verification:

- bun -F @cline/cline-hub build:webview

- bun biome check apps/cline-hub/src/webview/vite.config.ts apps/cline-hub/src/webview/src/App.tsx apps/cline-hub/src/webview/src/components/ai-elements/code-block.tsx apps/cline-hub/src/webview/src/components/ai-elements/message.tsx apps/cline-hub/src/webview/src/components/ai-elements/reasoning.tsx apps/cline-hub/src/webview/src/components/ai-elements/streamdown.tsx apps/cline-hub/src/webview/package.json

* address feedback
2026-06-22 16:40:01 -07:00
Tomás Barreiro 6ddf48d227 Forcefully send users to their personal dashboard when going to subscribe (#11725)
* Forcefully send users to their personal dashboard when going to subscribe

* fix trailing slash
2026-06-22 22:27:31 +02:00
Saoud Rizwan ee59f81706 chore(cli): release v3.0.29 2026-06-20 00:30:51 -07:00
79 changed files with 5238 additions and 1219 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [4.0.1]
### Changed
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
## [3.89.2]
### Fixed
+6
View File
@@ -1,5 +1,11 @@
# Cline CLI Changelog
## 3.0.29
- Costs are now hidden for Cline free models
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.28",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+2 -36
View File
@@ -1,36 +1,2 @@
export type ConnectorCatalogEntry = {
name: string;
description: string;
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
},
{
name: "gchat",
description: "Google Chat webhook bridge backed by RPC runtime sessions",
},
{
name: "linear",
description: "Linear webhook bridge backed by RPC runtime sessions",
},
{
name: "slack",
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
},
{
name: "telegram",
description: "Bridge Telegram bot messages into RPC chat sessions",
},
{
name: "whatsapp",
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
},
];
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
}
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
+14 -1
View File
@@ -38,10 +38,14 @@ const sessionEventsMocks = vi.hoisted(() => ({
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
@@ -49,6 +53,15 @@ vi.mock("@cline/core", () => ({
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -3,7 +3,9 @@ import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
@@ -329,6 +331,30 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
);
}
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
}) {
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">Personal ClinePass required</text>
<text
fg={props.defaultFg}
selectable
content={getClineOrgIndividualInferenceSubscriptionMessage()}
/>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -427,6 +453,11 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
);
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: true,
}),
).toBe("(12,345) $0.12");
).toBe("(12,345 tokens) $0.12");
});
it("omits cost when usage cost is hidden", () => {
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: false,
}),
).toBe("(12,345)");
).toBe("(12,345 tokens)");
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
totalCost: number;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()})`;
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
+19 -1
View File
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -20,7 +22,23 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription/",
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
+30 -1
View File
@@ -1,10 +1,16 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export { getClinePassSubscriptionUrl };
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
@@ -32,7 +38,30 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
);
}
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
error: unknown,
): boolean {
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
);
}
return (
typeof error === "string" &&
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
error === getClineOrgIndividualInferenceSubscriptionMessage())
);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (error instanceof Error) {
return error.message;
}
+14 -323
View File
@@ -1,325 +1,16 @@
export interface PlatformDef {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: FieldDef[];
security?: SecurityDef;
}
import {
CONNECTOR_PLATFORMS,
shouldIncludeConnectorField,
} from "@cline/shared";
export interface FieldDef {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: FieldCondition;
}
export type {
ConnectorFieldCondition as FieldCondition,
ConnectorFieldDef as FieldDef,
ConnectorPlatformDef as PlatformDef,
ConnectorSecurityDef as SecurityDef,
ConnectorSecurityFieldDef as SecurityFieldDef,
} from "@cline/shared";
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
export type FieldCondition = {
flag: string;
equals?: string;
notEquals?: string;
};
export interface SecurityFieldDef {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
validate?: (value: string) => string | undefined;
}
export interface SecurityDef {
prompt: string;
fields: SecurityFieldDef[];
buildArgs: (values: Record<string, string>) => string[];
}
export function shouldIncludeField(
field: FieldDef,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function validateTelegramUserId(value: string): string | undefined {
return /^\d+$/.test(value)
? undefined
: "Telegram user ID must contain digits only";
}
function validateSlackTeamId(value: string): string | undefined {
return /^T[A-Z0-9]+$/.test(value)
? undefined
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
}
function validateSlackUserId(value: string): string | undefined {
return /^[UW][A-Z0-9]+$/.test(value)
? undefined
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
}
export const PLATFORMS: PlatformDef[] = [
{
id: "telegram",
name: "Telegram",
type: "polling",
hint: "Easiest to set up. No public URL needed.",
fields: [
{
flag: "-k",
label: "Bot token",
placeholder: "7123456789:AAH...",
required: true,
help: [
"Open Telegram and start a chat with @BotFather",
"Send /newbot and follow the prompts",
"BotFather gives you this after creating the bot",
"It looks like 7123456789:AAHxxx...",
],
},
],
security: {
prompt:
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
fields: [
{
key: "userId",
label: "Your Telegram user ID",
placeholder: "123456789",
help: [
"Message @userinfobot on Telegram",
"It will reply with your numeric user ID",
],
requiredMessage: "User ID is required to restrict access",
validate: validateTelegramUserId,
},
],
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
},
},
{
id: "slack",
name: "Slack",
type: "hybrid",
hint: "Public URL for webhook mode; leave blank for socket mode.",
fields: [
{
flag: "--bot-token",
label: "Bot token",
placeholder: "xoxb-...",
required: true,
help: [
"Go to api.slack.com/apps and create a new app",
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
"Install to workspace and copy the Bot Token",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "leave blank for socket mode",
help: [
"Enter a publicly accessible URL for webhook mode",
"Leave blank to use Slack socket mode instead",
],
},
{
flag: "--signing-secret",
label: "Signing secret",
required: true,
help: ["Found in your app's Basic Information page"],
includeWhen: { flag: "--base-url", notEquals: "" },
},
{
flag: "--app-token",
label: "App-level token",
placeholder: "xapp-...",
required: true,
help: [
"Enable Socket Mode in the Slack app",
"Generate an app-level token with the connections:write scope",
],
includeWhen: { flag: "--base-url", equals: "" },
},
],
security: {
prompt: "Restrict which Slack users can interact with the bot?",
fields: [
{
key: "teamId",
label: "Allowed Slack workspace ID",
placeholder: "T01ABC123",
help: [
"Open your Slack workspace URL in a browser",
"The workspace ID is the segment after /client/, for example T01ABC123",
],
requiredMessage: "Workspace ID is required to restrict access",
validate: validateSlackTeamId,
},
{
key: "userId",
label: "Allowed Slack member ID",
placeholder: "U01ABC123",
help: [
"Click a user's name in Slack, then View full profile",
"Click ... and Copy member ID",
],
requiredMessage: "Member ID is required to restrict access",
validate: validateSlackUserId,
},
],
buildArgs: ({ teamId, userId }) => [
"--hook-command",
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
],
},
},
{
id: "discord",
name: "Discord",
type: "webhook",
hint: "Requires a Discord app and public URL.",
fields: [
{
flag: "--application-id",
label: "Application ID",
required: true,
help: [
"Go to discord.com/developers/applications",
"Create a new app, copy the Application ID",
],
},
{
flag: "--bot-token",
label: "Bot token",
required: true,
help: ["Go to Bot section, create a bot, copy the token"],
},
{
flag: "--public-key",
label: "Public key",
required: true,
help: ["Found in General Information of your app"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
help: [
"Base URL for the connector",
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
],
},
],
},
{
id: "whatsapp",
name: "WhatsApp",
type: "webhook",
hint: "Requires Meta developer account and public URL.",
fields: [
{
flag: "--phone-number-id",
label: "Phone number ID",
required: true,
help: ["From your WhatsApp Business account in Meta Developer portal"],
},
{
flag: "--access-token",
label: "Access token",
required: true,
help: ["Generate a permanent token in Meta Developer portal"],
},
{
flag: "--app-secret",
label: "App secret",
required: true,
help: ["Found in App Settings > Basic"],
},
{
flag: "--verify-token",
label: "Webhook verify token",
placeholder: "my-verify-token",
required: true,
help: ["Any string you choose, used to verify webhook setup"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "gchat",
name: "Google Chat",
type: "webhook",
hint: "Requires Google Cloud project and public URL.",
fields: [
{
flag: "--credentials-json",
label: "Service account credentials JSON",
required: true,
help: [
"Create a service account in Google Cloud Console",
"Download the credentials JSON file",
"Paste the JSON content here",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "linear",
name: "Linear",
type: "webhook",
hint: "React to Linear issues and comments.",
fields: [
{
flag: "--api-key",
label: "API key",
required: true,
help: ["Go to Linear Settings > API > Personal API keys"],
},
{
flag: "--webhook-secret",
label: "Webhook signing secret",
required: true,
help: [
"Go to Settings > API > Webhooks, create one",
"Copy the signing secret",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
];
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
+21 -2
View File
@@ -1,3 +1,5 @@
import { isIP } from "node:net";
export interface ClineHubServerOptions {
host: string;
port: number;
@@ -47,6 +49,9 @@ function normalizePublicUrl(
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
);
}
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
parsed.port = String(port);
}
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}
@@ -85,12 +90,26 @@ export function resolveClineHubServerOptions(
};
}
function isDefaultProtocolPort(url: URL, port: number): boolean {
return (
(url.protocol === "http:" && port === 80) ||
(url.protocol === "https:" && port === 443)
);
}
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
if (url.port || isDefaultProtocolPort(url, port)) return false;
const hostname = url.hostname.replace(/^\[|\]$/g, "");
return hostname === "localhost" || isIP(hostname) !== 0;
}
export function buildInviteUrl(
publicUrl: string,
roomSecret: string | undefined,
): string {
if (!roomSecret) return publicUrl;
const url = new URL(publicUrl);
url.searchParams.set("roomSecret", roomSecret);
if (roomSecret) {
url.searchParams.set("roomSecret", roomSecret);
}
return url.toString();
}
+42 -9
View File
@@ -4,6 +4,7 @@ import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
} from "./server/approvals";
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
import {
browserConfig,
host,
@@ -14,7 +15,11 @@ import {
webviewDistDir,
} from "./server/deps";
import { handleDesktopCommand } from "./server/desktop-commands";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
createJsonResponse,
isWebviewRoute,
WebviewAssets,
} from "./server/http";
import {
attachHub,
detachHub,
@@ -53,17 +58,33 @@ export interface ClineHubDashboardServer {
stop: () => Promise<void>;
}
const PUBLIC_BROWSER_PATHS = new Set([
"/version",
"/health",
"/config.json",
"/api/marketplace/catalog",
"/icon.png",
"/icon.svg",
"/icon.ico",
"/32x32.png",
"/cline-logo-filled.svg",
"/favicon.svg",
]);
function isPublicStaticAssetPath(pathname: string): boolean {
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
}
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
}
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
await attachHub(ctx);
const healthInterval = setInterval(() => {
void (async () => {
@@ -77,6 +98,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (
!isAuthorizedBrowserToDesktopRequest(
req,
url,
{
bindHost: host,
port,
publicUrl,
roomSecret,
},
isPublicBrowserRoute,
)
) {
return createJsonResponse({ error: "unauthorized_browser" }, 403);
}
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
@@ -85,9 +121,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
@@ -0,0 +1,359 @@
import { describe, expect, it } from "vitest";
import {
allowedBrowserHosts,
allowedBrowserOrigins,
isAuthorizedBrowserRequest,
isAuthorizedBrowserToDesktopRequest,
requiresBrowserRequestAuth,
} from "./browser-auth";
const defaultOptions = {
bindHost: "127.0.0.1",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
};
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
function browserRequest(
origin?: string,
init?: Omit<RequestInit, "headers"> & {
headers?: Record<string, string>;
},
): Request {
return new Request("http://127.0.0.1:8787/browser", {
...init,
headers: {
host: "127.0.0.1:8787",
...(origin === undefined ? {} : { origin }),
...(init?.headers ?? {}),
},
});
}
describe("allowedBrowserOrigins", () => {
it("allows the configured public URL origin and local aliases for local binds", () => {
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
"http://127.0.0.1:8787",
"http://[::1]:8787",
"http://localhost:8787",
]);
});
it("uses the configured public URL scheme for local aliases", () => {
expect(
[
...allowedBrowserOrigins({
...defaultOptions,
publicUrl: "https://127.0.0.1:8787",
}),
].sort(),
).toEqual([
"https://127.0.0.1:8787",
"https://[::1]:8787",
"https://localhost:8787",
]);
});
it("omits default protocol ports for local alias origins", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
});
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
});
});
describe("allowedBrowserHosts", () => {
it("allows the configured public URL host and local aliases for local binds", () => {
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
"127.0.0.1:8787",
"[::1]:8787",
"localhost:8787",
]);
});
it("omits default protocol ports for local alias hosts", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
});
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
});
});
describe("requiresBrowserRequestAuth", () => {
it("does not require browser auth for public GET routes", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
publicRoute,
),
).toBe(false);
});
it("requires browser auth for unknown paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api"),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for privileged paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/browser"),
new URL("http://127.0.0.1:8787/browser"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every WebSocket upgrade path", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-socket", {
headers: { upgrade: "websocket" },
}),
new URL("http://127.0.0.1:8787/future-socket"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every unsafe HTTP method", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
});
describe("isAuthorizedBrowserRequest", () => {
it.each([
"http://127.0.0.1:8787",
"http://localhost:8787",
"http://[::1]:8787",
])("accepts local dashboard origin %s without a room secret", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(true);
});
it.each([
undefined,
"",
"null",
"not a url",
"http://evil.attacker.example.com",
"http://127.0.0.1:9999",
"https://127.0.0.1:8787",
])("rejects untrusted origin %s", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it.each([
undefined,
"",
"evil.attacker.example.com",
"127.0.0.1:9999",
"localhost:9999",
])("rejects untrusted host %s", (host) => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: host === undefined ? { host: "" } : { host },
}),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://0.0.0.0:8787", {
headers: { host: "0.0.0.0:8787" },
}),
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
{
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
roomSecret: "invite-123",
},
),
).toBe(true);
});
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
const options = { ...defaultOptions, roomSecret: "invite-123" };
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(true);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://evil.attacker.example.com"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: { host: "evil.attacker.example.com" },
}),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
});
});
describe("isAuthorizedBrowserToDesktopRequest", () => {
it("allows safe public GET routes without an origin", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
it("rejects future WebSocket paths from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-socket", {
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
upgrade: "websocket",
},
}),
new URL("http://127.0.0.1:8787/future-socket"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("allows future unsafe HTTP routes from trusted origins", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://127.0.0.1:8787",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { isNonLocalBindHost } from "../options";
export interface BrowserRequestAuthOptions {
bindHost: string;
port: number;
publicUrl: string;
roomSecret?: string;
}
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
function isWebSocketUpgrade(req: Request): boolean {
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
}
function parseOrigin(value: string | null): string | undefined {
const origin = parseHeader(value);
try {
return new URL(origin ?? "").origin;
} catch {
return undefined;
}
}
function parseHeader(value: string | null): string | undefined {
const host = value?.trim().toLowerCase();
return host || undefined;
}
function formatHostForOrigin(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function isDefaultProtocolPort(protocol: string, port: number): boolean {
return (
(protocol === "http:" && port === 80) ||
(protocol === "https:" && port === 443)
);
}
function originForHost(protocol: string, host: string, port: number): string {
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
}
function hostHeaderForHost(
protocol: string,
host: string,
port: number,
): string {
const formattedHost = formatHostForOrigin(host).toLowerCase();
return isDefaultProtocolPort(protocol, port)
? formattedHost
: `${formattedHost}:${port}`;
}
export function allowedBrowserOrigins({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const origins = new Set<string>();
origins.add(publicUrlParts.origin);
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
}
}
return origins;
}
export function allowedBrowserHosts({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const hosts = new Set<string>();
const publicHost = publicUrlParts.host.toLowerCase();
hosts.add(publicHost);
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
}
}
return hosts;
}
export function requiresBrowserRequestAuth(
req: Request,
url: URL,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
if (isWebSocketUpgrade(req)) return true;
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
return !isPublicBrowserRoute(req, url);
}
export function isAuthorizedBrowserRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
): boolean {
const host = parseHeader(req.headers.get("host"));
if (!host || !allowedBrowserHosts(options).has(host)) return false;
const origin = parseOrigin(req.headers.get("origin"));
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
if (!options.roomSecret) return true;
return url.searchParams.get("roomSecret") === options.roomSecret;
}
export function isAuthorizedBrowserToDesktopRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
return (
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
isAuthorizedBrowserRequest(req, url, options)
);
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
describe("connector launch command", () => {
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Users/test/.bun/bin/bun",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("uses compiled CLI subcommands without Bun flags", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Applications/Cline/bin/cline",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Applications/Cline/bin/cline",
childArgs: ["connect", "telegram", "--bot-token", "token"],
});
});
it("uses Bun conditions when launching the source CLI from Node", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/usr/local/bin/node",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("detects Windows Node when launching the source CLI", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "node.exe",
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"C:\\repo\\apps\\cli\\src\\index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("strips terminal color codes from connector command failures", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
"connector start failed",
),
).toBe("unknown option '--conditions=development'");
});
it("turns Telegram unauthorized responses into a token validation message", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
"connector start failed",
),
).toBe(
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
});
+86 -23
View File
@@ -1,5 +1,8 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
@@ -13,6 +16,68 @@ import type {
import { cliIndexPath, workspaceRoot } from "./deps";
import { asRecord, asString } from "./utils";
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath = options.cliPath ?? cliIndexPath;
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
stdout: string;
stderr: string;
}> {
const launcher = (process.versions as Record<string, string | undefined>).bun
? process.execPath
: "bun";
const child = spawn(
launcher,
["--conditions=development", cliIndexPath, "connect", ...args],
{
cwd: workspaceRoot,
env: {
...process.env,
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
const { launcher, childArgs } = buildCliConnectCommand(args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
normalizeConnectorError,
};
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
+17
View File
@@ -41,6 +41,23 @@ expectEqual(
"invite URL",
);
const tailscale = resolveClineHubServerOptions({
HOST: "0.0.0.0",
CLINE_HUB_DASHBOARD_PORT: "8787",
PUBLIC_URL: "http://100.82.5.118",
ROOM_SECRET: "invite-123",
});
expectEqual(
tailscale.publicUrl,
"http://100.82.5.118:8787",
"direct IP public URL gets dashboard port",
);
expectEqual(
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
"http://100.82.5.118:8787/?roomSecret=invite-123",
"invite URL for direct IP public URL",
);
expectThrows(
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
"non-local bind without ROOM_SECRET",
+3 -3
View File
@@ -13,10 +13,9 @@
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^6.0.116",
@@ -27,6 +26,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
+31 -11
View File
@@ -22,7 +22,14 @@ import {
WrenchIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -53,19 +60,24 @@ import type {
WebviewOutboundMessage,
WebviewSessionSummary,
} from "../../webview-protocol";
import Chat from "./Chat";
import { PageFrame, PageHeader } from "./components/views/page-layout";
import {
type CustomizationSection,
CustomizationSectionView,
} from "./components/views/settings/extensions-view";
import {
type SettingsSection,
SettingsView,
} from "./components/views/settings/settings-view";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
const Chat = lazy(() => import("./Chat"));
const SettingsView = lazy(() =>
import("./components/views/settings/settings-view").then((module) => ({
default: module.SettingsView,
})),
);
const CustomizationSectionView = lazy(() =>
import("./components/views/settings/extensions-view").then((module) => ({
default: module.CustomizationSectionView,
})),
);
type View =
| "home"
| "sessions"
@@ -238,6 +250,14 @@ function currentPathWithSearch(): string {
return `${window.location.pathname}${window.location.search}`;
}
function ViewLoading() {
return (
<PageFrame>
<p className="text-sm text-muted-foreground">Loading...</p>
</PageFrame>
);
}
function formatRelativeTime(timestamp?: number): string {
if (!timestamp) return "unknown";
const elapsed = Math.max(0, Date.now() - timestamp);
@@ -1333,7 +1353,7 @@ function App() {
return (
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
{content}
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
</Shell>
);
}
@@ -11,12 +11,13 @@ import {
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
HighlighterCore,
LanguageRegistration,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
ThemeRegistration,
} from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -36,6 +37,78 @@ const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
const SUPPORTED_LANGUAGES = [
"bash",
"css",
"diff",
"html",
"javascript",
"json",
"jsonc",
"jsx",
"markdown",
"python",
"shellscript",
"tsx",
"typescript",
"yaml",
] as const;
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
const LANGUAGE_LOADERS: Record<
SupportedCodeLanguage,
() => Promise<LanguageRegistration[]>
> = {
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
css: () => import("@shikijs/langs/css").then((module) => module.default),
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
html: () => import("@shikijs/langs/html").then((module) => module.default),
javascript: () =>
import("@shikijs/langs/javascript").then((module) => module.default),
json: () => import("@shikijs/langs/json").then((module) => module.default),
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
markdown: () =>
import("@shikijs/langs/markdown").then((module) => module.default),
python: () =>
import("@shikijs/langs/python").then((module) => module.default),
shellscript: () =>
import("@shikijs/langs/shellscript").then((module) => module.default),
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
typescript: () =>
import("@shikijs/langs/typescript").then((module) => module.default),
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
};
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
console: "shellscript",
cjs: "javascript",
htm: "html",
js: "javascript",
json5: "jsonc",
md: "markdown",
mjs: "javascript",
py: "python",
sh: "shellscript",
shell: "shellscript",
ts: "typescript",
yml: "yaml",
};
const normalizeLanguage = (
language: string,
): SupportedCodeLanguage | "text" => {
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "text";
}
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
};
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
@@ -108,7 +181,7 @@ const LineSpan = ({
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
};
@@ -128,10 +201,9 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
let highlighterPromise: Promise<HighlighterCore> | undefined;
let themesPromise: Promise<void> | undefined;
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
@@ -139,27 +211,44 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: string) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
const getHighlighter = (): Promise<HighlighterCore> => {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
}
return highlighterPromise;
};
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
if (!themesPromise) {
themesPromise = Promise.all([
import("@shikijs/themes/github-light").then((module) => module.default),
import("@shikijs/themes/github-dark").then((module) => module.default),
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
}
return themesPromise;
};
const ensureLanguage = (
highlighter: HighlighterCore,
language: SupportedCodeLanguage,
): Promise<void> => {
const cached = languagePromises.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
highlighter.loadLanguage(...registrations),
);
languagePromises.set(language, languagePromise);
return languagePromise;
};
// Create raw tokens for immediate display while highlighting loads
@@ -181,11 +270,16 @@ const createRawTokens = (code: string): TokenizedCode => ({
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
language: string,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
const langToUse = normalizeLanguage(language);
if (langToUse === "text") {
return createRawTokens(code);
}
const tokensCacheKey = getTokensCacheKey(code, langToUse);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -202,11 +296,11 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
getHighlighter()
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
.then(async (highlighter) => {
await ensureThemes(highlighter);
await ensureLanguage(highlighter, langToUse);
const result = highlighter.codeToTokens(code, {
lang: langToUse,
@@ -376,7 +470,7 @@ export const CodeBlockContent = ({
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
@@ -1,9 +1,5 @@
"use client";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -16,7 +12,6 @@ import {
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
@@ -26,6 +21,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { HubStreamdown } from "./streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -316,24 +312,18 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
<HubStreamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = "MessageResponse";
@@ -1,10 +1,6 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -17,7 +13,6 @@ import {
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
@@ -26,6 +21,7 @@ import {
import { cn } from "@/lib/utils";
import { Shimmer } from "./shimmer";
import { HubStreamdown } from "./streamdown";
interface ReasoningContextValue {
isStreaming: boolean;
@@ -204,8 +200,6 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -216,7 +210,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
<HubStreamdown>{children}</HubStreamdown>
</CollapsibleContent>
),
);
@@ -0,0 +1,170 @@
import { cjk } from "@streamdown/cjk";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement, memo } from "react";
import {
type Components,
type DiagramPlugin,
Streamdown,
type StreamdownProps,
} from "streamdown";
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopyButton,
CodeBlockFilename,
CodeBlockHeader,
CodeBlockTitle,
} from "@/components/ai-elements/code-block";
import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
node?: {
properties?: {
metastring?: string;
};
};
};
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
function codeText(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) {
return children.map(codeText).join("");
}
if (isValidElement<{ children?: ReactNode }>(children)) {
return codeText(children.props.children);
}
return "";
}
const MarkdownCode = ({
children,
className,
node,
"data-block": dataBlock,
...props
}: MarkdownCodeProps) => {
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
if (!dataBlock) {
return (
<code
className={cn(
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
className,
)}
{...props}
>
{children}
</code>
);
}
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
return (
<CodeBlock
code={codeText(children)}
data-start-line={startLine > 1 ? startLine : undefined}
language={language}
showLineNumbers={showLineNumbers}
>
<CodeBlockHeader>
<CodeBlockTitle>
<CodeBlockFilename>{language}</CodeBlockFilename>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
);
};
const markdownComponents = {
code: MarkdownCode,
} satisfies Components;
const DEFAULT_MERMAID_CONFIG = {
fontFamily: "monospace",
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default",
} satisfies MermaidConfig;
interface LazyMermaidInstance {
initialize: (config: MermaidConfig) => void;
render: (
id: string,
source: string,
) => Promise<{
svg: string;
}>;
}
function createLazyMermaidPlugin(): DiagramPlugin {
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
let initialized = false;
const instance: LazyMermaidInstance = {
initialize(nextConfig: MermaidConfig) {
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
initialized = false;
},
async render(id: string, source: string) {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;
if (!initialized) {
mermaid.initialize(config);
initialized = true;
}
return mermaid.render(id, source);
},
};
return {
getMermaid(nextConfig?: MermaidConfig) {
if (nextConfig) {
instance.initialize(nextConfig);
}
return instance;
},
language: "mermaid",
name: "mermaid",
type: "diagram",
};
}
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
export type HubStreamdownProps = StreamdownProps;
export const HubStreamdown = memo(
({ className, components, ...props }: HubStreamdownProps) => {
const mergedComponents = components
? { ...markdownComponents, ...components }
: markdownComponents;
return (
<Streamdown
className={className}
components={mergedComponents}
plugins={streamdownPlugins}
{...props}
/>
);
},
);
HubStreamdown.displayName = "HubStreamdown";
+29
View File
@@ -3,6 +3,27 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
const mermaidChunkGroups = [
{
name: "mermaid-parser",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
},
{
name: "mermaid-langium",
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
},
{
name: "mermaid-layout",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
},
{
name: "mermaid-markup",
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
},
];
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -25,5 +46,13 @@ export default defineConfig({
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 600,
rolldownOptions: {
output: {
codeSplitting: {
groups: mermaidChunkGroups,
},
},
},
},
});
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.2",
"version": "4.0.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+2
View File
@@ -921,6 +921,7 @@ export class Controller {
const environment = clineConfig.environment
const banners = BannerService.get().getActiveBanners() ?? []
const welcomeBanners = BannerService.get().getWelcomeBanners() ?? []
const modelsDevProviderModels = this.stateManager.getModelsDevProviderModelsCache() ?? undefined
// Check OpenAI Codex authentication status
const { openAiCodexOAuthManager } = await import("@/integrations/openai-codex/oauth")
@@ -969,6 +970,7 @@ export class Controller {
isNewUser,
welcomeViewCompleted,
onboardingModels,
modelsDevProviderModels,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -0,0 +1,84 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { applyModelsDevProviderModels, type ModelsDevProviderModels, normalizeModelsDevProviderModels } from "@shared/models-dev"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { StateManager } from "@/core/storage/StateManager"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
export const MODELS_DEV_CATALOG_URL = "https://models.dev/api.json"
let pendingRefresh: Promise<ModelsDevProviderModels> | null = null
export async function refreshModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const cache = StateManager.get().getModelsDevProviderModelsCache()
if (cache) {
applyModelsDevProviderModels(cache)
return cache
}
if (pendingRefresh) {
return pendingRefresh
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheModelsDevProviderModels()
} finally {
pendingRefresh = null
}
})()
return pendingRefresh
}
async function fetchAndCacheModelsDevProviderModels(): Promise<ModelsDevProviderModels> {
const modelsDevProviderModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.modelsDevProviderModels)
let providerModels: ModelsDevProviderModels = {}
try {
const response = await axios.get(MODELS_DEV_CATALOG_URL, getAxiosSettings())
providerModels = normalizeModelsDevProviderModels(response.data)
if (Object.keys(providerModels).length === 0) {
throw new Error("No supported models.dev provider models found")
}
await fs.writeFile(modelsDevProviderModelsFilePath, JSON.stringify(providerModels))
Logger.log("models.dev provider models fetched and saved")
} catch (error) {
Logger.error("Error fetching models.dev provider models:", error)
const cachedModels = await readModelsDevProviderModelsFromCache()
if (cachedModels && Object.keys(cachedModels).length > 0) {
providerModels = cachedModels
Logger.log("Loaded models.dev provider models from cache")
}
}
if (Object.keys(providerModels).length > 0) {
applyModelsDevProviderModels(providerModels)
StateManager.get().setModelsDevProviderModelsCache(providerModels)
}
return providerModels
}
export async function readModelsDevProviderModelsFromCache(): Promise<ModelsDevProviderModels | undefined> {
try {
const modelsDevProviderModelsFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.modelsDevProviderModels,
)
const fileExists = await fileExistsAtPath(modelsDevProviderModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(modelsDevProviderModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
} catch (error) {
Logger.error("Error reading cached models.dev provider models:", error)
}
return undefined
}
@@ -11,6 +11,7 @@ import { refreshClineModels } from "../models/refreshClineModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshHicapModels } from "../models/refreshHicapModels"
import { refreshLiteLlmModels } from "../models/refreshLiteLlmModels"
import { refreshModelsDevProviderModels } from "../models/refreshModelsDevProviderModels"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
@@ -28,6 +29,14 @@ export async function initializeWebview(controller: Controller, _request: EmptyR
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
}
refreshModelsDevProviderModels()
.then(async (models) => {
if (models && Object.keys(models).length > 0) {
await controller.postStateToWebview()
}
})
.catch((error) => Logger.error("Failed to refresh models.dev provider models:", error))
// Refresh OpenRouter models from API
refreshOpenRouterModels(controller).then(async (models) => {
if (models && Object.keys(models).length > 0) {
@@ -1,4 +1,5 @@
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import type { ModelsDevProviderModels } from "@shared/models-dev"
import {
ApiHandlerSettingsKeys,
type GlobalState,
@@ -102,6 +103,7 @@ export class StateManager {
liteLlmModels: null,
vercelModels: null,
}
private modelsDevProviderModelsCache: { data: ModelsDevProviderModels; timestamp: number } | null = null
// Debounced persistence state
private pendingGlobalState = new Set<GlobalStateAndSettingsKey>()
@@ -502,6 +504,24 @@ export class StateManager {
return cached.data
}
setModelsDevProviderModelsCache(models: ModelsDevProviderModels): void {
this.modelsDevProviderModelsCache = { data: models, timestamp: Date.now() }
}
getModelsDevProviderModelsCache(): ModelsDevProviderModels | null {
const cached = this.modelsDevProviderModelsCache
if (!cached) {
return null
}
if (Date.now() - cached.timestamp > this.MODEL_CACHE_TTL_MS) {
this.modelsDevProviderModelsCache = null
return null
}
return cached.data
}
/**
* Get model info by provider and model ID (from in-memory cache)
*/
+1
View File
@@ -51,6 +51,7 @@ export const GlobalFileNames = {
uiMessages: "ui_messages.json",
clineRecommendedModels: "cline_recommended_models.json",
clineModels: "cline_models.json",
modelsDevProviderModels: "models_dev_provider_models.json",
openRouterModels: "openrouter_models.json",
vercelAiGatewayModels: "vercel_ai_gateway_models.json",
groqModels: "groq_models.json",
+6 -1
View File
@@ -2469,6 +2469,9 @@ export class Task {
const isEntitlementError = clineError.isErrorType(
ClineErrorType.Entitlement,
);
const isOrgClinePassRestrictionError = clineError.isErrorType(
ClineErrorType.OrgClinePassRestriction,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2495,6 +2498,7 @@ export class Task {
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
!isOrgClinePassRestrictionError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2557,7 +2561,8 @@ export class Task {
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError;
!isEntitlementError &&
!isOrgClinePassRestrictionError;
if (showRetry) {
await this.say(
"error_retry",
File diff suppressed because it is too large Load Diff
+105 -53
View File
@@ -1,5 +1,5 @@
import { serializeError } from "serialize-error"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
import { serializeError } from "serialize-error";
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount";
export enum ClineErrorType {
Auth = "auth",
@@ -9,46 +9,57 @@ export enum ClineErrorType {
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
OrgClinePassRestriction = "orgClinePassRestriction",
}
interface ErrorDetails {
/**
* The HTTP status code of the error, if applicable.
*/
status?: number
status?: number;
/**
* The request ID associated with the error, if available.
* This can be useful for debugging and support.
*/
request_id?: string
request_id?: string;
/**
* Specific error code provided by the API or service.
*/
code?: string
code?: string;
/**
* The model ID associated with the error, if applicable.
* This is useful for identifying which model the error relates to.
*/
modelId?: string
modelId?: string;
/**
* The provider ID associated with the error, if applicable.
* This is useful for identifying which provider the error relates to.
*/
providerId?: string
providerId?: string;
/**
* The error message associated with the error, if applicable.
*/
message?: string
message?: string;
// Additional details that might be present in the error
// This can include things like current balance, error messages, etc.
details?: any
details?: any;
}
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
const RATE_LIMIT_PATTERNS = [
/status code 429/i,
/rate limit/i,
/too many requests/i,
/quota exceeded/i,
/resource exhausted/i,
];
const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
"organization accounts cannot use individual model inference subscriptions";
const ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE =
"organization accounts cannot use clinepass subscriptions";
export class ClineError extends Error {
readonly title = "ClineError"
readonly _error: ErrorDetails
readonly title = "ClineError";
readonly _error: ErrorDetails;
// Error details per providers:
// Cline: error?.error
@@ -59,15 +70,19 @@ export class ClineError extends Error {
public readonly modelId?: string,
public readonly providerId?: string,
) {
const error = serializeError(raw)
const error = serializeError(raw);
const message = error.message || error?.response?.message || String(error) || error?.cause?.means
super(message)
const message =
error.message ||
error?.response?.message ||
String(error) ||
error?.cause?.means;
super(message);
// Extract status from multiple possible locations
const status = error.status || error.statusCode || error.response?.status
this.modelId = modelId || error.modelId
this.providerId = providerId || error.providerId
const status = error.status || error.statusCode || error.response?.status;
this.modelId = modelId || error.modelId;
this.providerId = providerId || error.providerId;
// Construct the error details object to includes relevant information
// And ensure it has a consistent structure
@@ -85,7 +100,7 @@ export class ClineError extends Error {
providerId: this.providerId,
details: error.details || error.error, // Additional details provided by the server
stack: undefined, // Avoid serializing stack trace to keep the error object clean
}
};
}
/**
@@ -101,7 +116,7 @@ export class ClineError extends Error {
modelId: this.modelId,
providerId: this.providerId,
details: this._error.details,
})
});
}
/**
@@ -109,29 +124,33 @@ export class ClineError extends Error {
*/
static parse(errorStr?: string, modelId?: string): ClineError | undefined {
if (!errorStr || typeof errorStr !== "string") {
return undefined
return undefined;
}
return ClineError.transform(errorStr, modelId)
return ClineError.transform(errorStr, modelId);
}
/**
* Transforms any object into a ClineError instance.
* Always returns a ClineError, even if the input is not a valid error object.
*/
static transform(error: any, modelId?: string, providerId?: string): ClineError {
static transform(
error: any,
modelId?: string,
providerId?: string,
): ClineError {
try {
// If already a ClineError, return it directly to prevent infinite recursion
if (error instanceof ClineError) {
return error
return error;
}
return new ClineError(JSON.parse(error), modelId, providerId)
return new ClineError(JSON.parse(error), modelId, providerId);
} catch {
return new ClineError(error, modelId, providerId)
return new ClineError(error, modelId, providerId);
}
}
public isErrorType(type: ClineErrorType): boolean {
return ClineError.getErrorType(this) === type
return ClineError.getErrorType(this) === type;
}
/**
@@ -139,53 +158,86 @@ export class ClineError extends Error {
* This is useful for determining how to handle the error in the UI or logic.
*/
static getErrorType(err: ClineError): ClineErrorType | undefined {
const { code, status, details } = err._error
const message = (err._error?.message || err.message || JSON.stringify(err._error))?.toLowerCase()
const { code, status, details } = err._error;
const message = (
err._error?.message ||
err.message ||
JSON.stringify(err._error)
)?.toLowerCase();
// Check balance error first (most specific)
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
return ClineErrorType.Balance
if (
code === "insufficient_credits" &&
typeof details?.current_balance === "number"
) {
return ClineErrorType.Balance;
}
// Check spend limit exceeded (org-enforced budget cap, 429 SPEND_LIMIT_EXCEEDED)
// Must be checked before the generic rate-limit check since both use 429
if (code === "SPEND_LIMIT_EXCEEDED" || details?.code === "SPEND_LIMIT_EXCEEDED") {
return ClineErrorType.SpendLimit
if (
code === "SPEND_LIMIT_EXCEEDED" ||
details?.code === "SPEND_LIMIT_EXCEEDED"
) {
return ClineErrorType.SpendLimit;
}
// Scoped to the individual "not subscribed" case; other ENTITLEMENT_ERROR variants (e.g. org
// accounts) fall through. Checked before the generic auth check since these are returned as 403.
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
return ClineErrorType.Entitlement
// ClinePass entitlement errors are user-actionable and should not fall through to generic 403 auth.
// The organization-account variant gets separate copy because subscribing is not the right action.
const isEntitlementCode =
code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR";
const entitlementText =
`${message ?? ""} ${details?.message ?? ""}`.toLowerCase();
if (
isEntitlementCode &&
(entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_MESSAGE) ||
entitlementText.includes(ORG_CLINE_PASS_RESTRICTION_USER_MESSAGE))
) {
return ClineErrorType.OrgClinePassRestriction;
}
if (
isEntitlementCode &&
entitlementText.includes("not subscribed to required model plan")
) {
return ClineErrorType.Entitlement;
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
return ClineErrorType.Auth
const isAuthStatus = status !== undefined && status > 400 && status < 429;
if (
code === "ERR_BAD_REQUEST" ||
err instanceof AuthInvalidTokenError ||
isAuthStatus
) {
return ClineErrorType.Auth;
}
if (code === "INFERENCE_CAP_ERROR") {
return ClineErrorType.QuotaExceeded
return ClineErrorType.QuotaExceeded;
}
if (message) {
// Check for specific error codes/messages if applicable
const authErrorRegex = [/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i, /authentication[-_ ]?failed/i, /unauthorized/i]
if (message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) || authErrorRegex.some((regex) => regex.test(message))) {
return ClineErrorType.Auth
const authErrorRegex = [
/(?:in)?valid[-_ ]?(?:api )?(?:token|key)/i,
/authentication[-_ ]?failed/i,
/unauthorized/i,
];
if (
message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) ||
authErrorRegex.some((regex) => regex.test(message))
) {
return ClineErrorType.Auth;
}
// Check rate limit patterns
const lowerMessage = message.toLowerCase()
const lowerMessage = message.toLowerCase();
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(lowerMessage))) {
return ClineErrorType.RateLimit
return ClineErrorType.RateLimit;
}
}
return undefined
return undefined;
}
}
@@ -194,14 +246,14 @@ export class AuthNetworkError extends Error {
message: string,
override readonly cause?: Error,
) {
super(message)
this.name = ClineErrorType.Network
super(message);
this.name = ClineErrorType.Network;
}
}
export class AuthInvalidTokenError extends Error {
constructor(message: string) {
super(message)
this.name = ClineErrorType.Auth
super(message);
this.name = ClineErrorType.Auth;
}
}
@@ -1,42 +1,52 @@
import { describe, it } from "mocha"
import "should"
import { ClineError, ClineErrorType } from "../ClineError"
import { describe, it } from "mocha";
import "should";
import { ClineError, ClineErrorType } from "../ClineError";
describe("ClineError", () => {
describe("getErrorType", () => {
it("should return QuotaExceeded when code is INFERENCE_CAP_ERROR", () => {
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
const err = new ClineError({
message: "Inference cap reached",
code: "INFERENCE_CAP_ERROR",
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded);
});
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
details: {
code: "ENTITLEMENT_ERROR",
message:
"Error 403: the user is not subscribed to required model plan",
},
});
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
});
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
@@ -45,25 +55,39 @@ describe("ClineError", () => {
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
message:
"Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
);
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement);
});
it("should NOT classify the organization ENTITLEMENT_ERROR variant as Entitlement", () => {
// Org accounts can't use individual subs; this case is intentionally out of scope and
// falls through to generic handling rather than showing the ClinePass card.
it("should classify the organization ENTITLEMENT_ERROR variant separately from the ClinePass subscription card", () => {
// Org accounts can't use individual subs; this case should not show the personal ClinePass
// subscription card, but it should still get dedicated user-actionable copy.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
message:
"403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.Entitlement).should.be.true()
})
})
})
});
const result = ClineError.getErrorType(err);
result!.should.equal(ClineErrorType.OrgClinePassRestriction);
(result !== ClineErrorType.Entitlement).should.be.true();
});
it("should not classify organization restriction text without ENTITLEMENT_ERROR as OrgClinePassRestriction", () => {
const err = new ClineError({
message:
"Network error: organization accounts cannot use individual model inference subscriptions",
code: "ERR_NETWORK",
});
const result = ClineError.getErrorType(err);
(result !== ClineErrorType.OrgClinePassRestriction).should.be.true();
});
});
});
@@ -13,6 +13,7 @@ import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { ClineMessageModelInfo } from "./messages"
import type { ModelsDevProviderModels } from "./models-dev"
import { OnboardingModelGroup } from "./proto/cline/state"
import { Mode } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
@@ -40,6 +41,7 @@ export interface ExtensionState {
isNewUser: boolean
welcomeViewCompleted: boolean
onboardingModels: OnboardingModelGroup | undefined
modelsDevProviderModels?: ModelsDevProviderModels
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
+124
View File
@@ -0,0 +1,124 @@
import { expect } from "chai"
import { afterEach, describe, it } from "mocha"
import { anthropicModels, type ModelInfo } from "./api"
import {
applyModelsDevProviderModels,
type ModelsDevPayload,
mergeModelsDevModels,
normalizeModelsDevProviderModels,
} from "./models-dev"
describe("models.dev static provider augmentation", () => {
const augmentedAnthropicModelId = "claude-test-model-from-models-dev"
afterEach(() => {
delete (anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]
})
it("normalizes supported models.dev models and filters unsupported entries", () => {
const payload: ModelsDevPayload = {
anthropic: {
models: {
[augmentedAnthropicModelId]: {
name: "Claude Test",
tool_call: true,
reasoning: true,
reasoning_options: [{ type: "effort", values: ["low", "medium", "high"] }],
release_date: "2026-01-01",
limit: {
context: 200_000,
output: 64_000,
},
cost: {
input: 3,
output: 15,
cache_read: 0.3,
cache_write: 3.75,
},
modalities: {
input: ["text", "image"],
},
},
"claude-deprecated": {
tool_call: true,
status: "deprecated",
},
"claude-no-tools": {
tool_call: false,
},
},
},
}
const providerModels = normalizeModelsDevProviderModels(payload)
const model = providerModels.anthropic?.[augmentedAnthropicModelId]
expect(model).to.not.equal(undefined)
expect(model?.name).to.equal("Claude Test")
expect(model?.contextWindow).to.equal(200_000)
expect(model?.maxTokens).to.equal(64_000)
expect(model?.supportsImages).to.equal(true)
expect(model?.supportsPromptCache).to.equal(true)
expect(model?.supportsReasoning).to.equal(true)
expect(model?.supportsReasoningEffort).to.equal(true)
expect(model?.inputPrice).to.equal(3)
expect(providerModels.anthropic?.["claude-deprecated"]).to.equal(undefined)
expect(providerModels.anthropic?.["claude-no-tools"]).to.equal(undefined)
})
it("keeps hardcoded model info while appending missing models.dev ids", () => {
const staticModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 1,
contextWindow: 1,
supportsPromptCache: false,
inputPrice: 1,
outputPrice: 1,
},
}
const modelsDevModels: Record<string, ModelInfo> = {
existing: {
maxTokens: 2,
contextWindow: 2,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 2,
},
added: {
maxTokens: 3,
contextWindow: 3,
supportsPromptCache: false,
inputPrice: 3,
outputPrice: 3,
},
}
const merged = mergeModelsDevModels(staticModels, modelsDevModels)
expect(merged.existing.maxTokens).to.equal(1)
expect(merged.added.maxTokens).to.equal(3)
expect(Object.keys(merged)).to.deep.equal(["existing", "added"])
})
it("applies missing models.dev ids to existing static provider maps", () => {
const modelInfo: ModelInfo = {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 3,
outputPrice: 15,
cacheReadsPrice: 0.3,
cacheWritesPrice: 3.75,
}
applyModelsDevProviderModels({
anthropic: {
[augmentedAnthropicModelId]: modelInfo,
},
})
expect((anthropicModels as Record<string, ModelInfo>)[augmentedAnthropicModelId]).to.deep.equal(modelInfo)
})
})
+236
View File
@@ -0,0 +1,236 @@
import {
type ApiProvider,
anthropicModels,
bedrockModels,
cerebrasModels,
deepSeekModels,
fireworksModels,
geminiModels,
huggingFaceModels,
internationalZAiModels,
type ModelInfo,
mainlandZAiModels,
minimaxModels,
mistralModels,
moonshotModels,
nebiusModels,
nousResearchModels,
openAiNativeModels,
sambanovaModels,
vertexModels,
wandbModels,
xaiModels,
} from "./api"
export type ModelsDevModelInfo = ModelInfo & {
releaseDate?: string
family?: string
supportsReasoningEffort?: boolean
supportsTools?: boolean
}
export type ModelsDevProviderModels = Partial<Record<ApiProvider, Record<string, ModelsDevModelInfo>>>
export interface ModelsDevModel {
name?: string
tool_call?: boolean
reasoning?: boolean
structured_output?: boolean
temperature?: boolean
reasoning_options?: {
type?: string
values?: string[]
min?: number
}[]
release_date?: string
family?: string
limit?: {
context?: number
input?: number
output?: number
}
cost?: {
input?: number
output?: number
cache_read?: number
cache_write?: number
}
modalities?: {
input?: string[]
}
status?: string
}
export type ModelsDevPayload = Record<string, { models?: Record<string, ModelsDevModel> }>
const DEFAULT_MAX_TOKENS = 4096
const MODELS_DEV_PROVIDER_KEY_MAP: Record<string, ApiProvider> = {
"amazon-bedrock": "bedrock",
anthropic: "anthropic",
cerebras: "cerebras",
deepseek: "deepseek",
"fireworks-ai": "fireworks",
google: "gemini",
"google-vertex": "vertex",
huggingface: "huggingface",
minimax: "minimax",
mistral: "mistral",
moonshotai: "moonshot",
nebius: "nebius",
"nous-research": "nousResearch",
openai: "openai-native",
sambanova: "sambanova",
wandb: "wandb",
xai: "xai",
zai: "zai",
}
const STATIC_MODELS_BY_PROVIDER: Partial<Record<ApiProvider, Record<string, ModelInfo>>> = {
anthropic: anthropicModels as Record<string, ModelInfo>,
bedrock: bedrockModels as Record<string, ModelInfo>,
cerebras: cerebrasModels as Record<string, ModelInfo>,
deepseek: deepSeekModels as Record<string, ModelInfo>,
fireworks: fireworksModels as Record<string, ModelInfo>,
gemini: geminiModels as Record<string, ModelInfo>,
huggingface: huggingFaceModels as Record<string, ModelInfo>,
minimax: minimaxModels as Record<string, ModelInfo>,
mistral: mistralModels as Record<string, ModelInfo>,
moonshot: moonshotModels as Record<string, ModelInfo>,
nebius: nebiusModels as Record<string, ModelInfo>,
nousResearch: nousResearchModels as Record<string, ModelInfo>,
"openai-native": openAiNativeModels as Record<string, ModelInfo>,
sambanova: sambanovaModels as Record<string, ModelInfo>,
vertex: vertexModels as Record<string, ModelInfo>,
wandb: wandbModels as Record<string, ModelInfo>,
xai: xaiModels as Record<string, ModelInfo>,
zai: internationalZAiModels as Record<string, ModelInfo>,
}
function parseReleaseDate(value: string | undefined): number {
if (!value) {
return Number.NEGATIVE_INFINITY
}
const timestamp = Date.parse(value)
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp
}
function sortModelsByReleaseDate(models: Record<string, ModelsDevModelInfo>): Record<string, ModelsDevModelInfo> {
return Object.fromEntries(
Object.entries(models).sort(([modelIdA, modelA], [modelIdB, modelB]) => {
const releaseDateA = parseReleaseDate(modelA.releaseDate)
const releaseDateB = parseReleaseDate(modelB.releaseDate)
if (releaseDateA !== releaseDateB) {
return releaseDateB - releaseDateA
}
return modelIdA.localeCompare(modelIdB)
}),
)
}
function hasCachePricing(cost: ModelsDevModel["cost"]): boolean {
return typeof cost?.cache_read === "number" || typeof cost?.cache_write === "number"
}
function supportsReasoningEffort(model: ModelsDevModel): boolean {
return model.reasoning_options?.some((option) => option.type === "effort") ?? false
}
function toModelInfo(modelId: string, model: ModelsDevModel): ModelsDevModelInfo {
const supportsPromptCache = hasCachePricing(model.cost)
const supportsReasoning = model.reasoning === true
const supportsEffort = supportsReasoningEffort(model)
const info: ModelsDevModelInfo = {
name: model.name || modelId,
maxTokens: Math.floor(model.limit?.output ?? DEFAULT_MAX_TOKENS),
contextWindow: model.limit?.context,
supportsImages: model.modalities?.input?.includes("image") ?? false,
supportsPromptCache,
supportsReasoning,
inputPrice: model.cost?.input ?? 0,
outputPrice: model.cost?.output ?? 0,
cacheReadsPrice: model.cost?.cache_read,
cacheWritesPrice: model.cost?.cache_write,
description: "",
thinkingConfig: supportsReasoning ? { maxBudget: model.limit?.output ?? DEFAULT_MAX_TOKENS } : undefined,
releaseDate: model.release_date,
family: model.family,
supportsReasoningEffort: supportsEffort,
supportsTools: model.tool_call === true,
}
return info
}
function isSupportedModelsDevModel(model: ModelsDevModel): boolean {
return model.tool_call === true && model.status !== "deprecated"
}
export function normalizeModelsDevProviderModels(payload: ModelsDevPayload): ModelsDevProviderModels {
const providerModels: ModelsDevProviderModels = {}
for (const [modelsDevProviderKey, providerId] of Object.entries(MODELS_DEV_PROVIDER_KEY_MAP)) {
const sourceModels = payload[modelsDevProviderKey]?.models
if (!sourceModels) {
continue
}
const models: Record<string, ModelsDevModelInfo> = {}
for (const [modelId, model] of Object.entries(sourceModels)) {
if (!isSupportedModelsDevModel(model)) {
continue
}
models[modelId] = toModelInfo(modelId, model)
}
if (Object.keys(models).length > 0) {
providerModels[providerId] = sortModelsByReleaseDate(models)
}
}
return providerModels
}
export function getStaticModelsForModelsDevProvider(providerId: ApiProvider): Record<string, ModelInfo> | undefined {
return STATIC_MODELS_BY_PROVIDER[providerId]
}
export function mergeModelsDevModels(
staticModels: Record<string, ModelInfo>,
modelsDevModels: Record<string, ModelInfo> | undefined,
): Record<string, ModelInfo> {
if (!modelsDevModels || Object.keys(modelsDevModels).length === 0) {
return staticModels
}
const additions = Object.fromEntries(Object.entries(modelsDevModels).filter(([modelId]) => !(modelId in staticModels)))
return {
...staticModels,
...additions,
}
}
export function applyModelsDevProviderModels(providerModels: ModelsDevProviderModels | undefined): void {
if (!providerModels) {
return
}
for (const [providerId, models] of Object.entries(providerModels) as [ApiProvider, Record<string, ModelInfo>][]) {
const staticModels = getStaticModelsForModelsDevProvider(providerId)
if (!staticModels) {
continue
}
for (const [modelId, modelInfo] of Object.entries(models)) {
if (!(modelId in staticModels)) {
staticModels[modelId] = modelInfo
}
if (providerId === "zai") {
const mainlandModels = mainlandZAiModels as Record<string, ModelInfo>
if (!(modelId in mainlandModels)) {
mainlandModels[modelId] = modelInfo
}
}
}
}
}
@@ -48,14 +48,14 @@ describe("EntitlementError", () => {
it("builds the subscribe link from the authenticated user's app base URL", () => {
mockAuth.clineUser = { appBaseUrl: "https://staging-app.cline.bot" }
const { unmount } = render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://staging-app.cline.bot/dashboard/subscription")
expect(getSubscribeHref()).toBe("https://staging-app.cline.bot/dashboard/subscription?personal=true")
unmount()
mockAuth.clineUser = {
appBaseUrl: "https://proxy.enterprise.com/cline/app",
}
render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://proxy.enterprise.com/cline/app/dashboard/subscription")
expect(getSubscribeHref()).toBe("https://proxy.enterprise.com/cline/app/dashboard/subscription?personal=true")
})
it("sends a yesButtonClicked askResponse when Retry Request is clicked", () => {
@@ -20,7 +20,9 @@ function buildSubscribeUrl(appBaseUrl?: string): string | undefined {
}
try {
const base = appBaseUrl.endsWith("/") ? appBaseUrl : `${appBaseUrl}/`
return new URL(CLINE_PASS_SUBSCRIBE_PATH, base).toString()
const url = new URL(CLINE_PASS_SUBSCRIBE_PATH, base)
url.searchParams.set("personal", "true")
return url.toString()
} catch {
// Malformed appBaseUrl: omit the link rather than crashing the error card.
return undefined
@@ -1,7 +1,9 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { render, screen } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import ErrorRow from "./ErrorRow"
import type { ClineMessage } from "@shared/ExtensionMessage";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ErrorRow from "./ErrorRow";
const mockSetUserOrganization = vi.hoisted(() => vi.fn());
// Mock the auth context
vi.mock("@/context/ClineAuthContext", () => ({
@@ -12,17 +14,27 @@ vi.mock("@/context/ClineAuthContext", () => ({
isLoginLoading: false,
}),
handleSignOut: vi.fn(),
}))
}));
// Mock CreditLimitError component
vi.mock("@/components/chat/CreditLimitError", () => ({
default: ({ message }: { message: string }) => <div data-testid="credit-limit-error">{message}</div>,
}))
default: ({ message }: { message: string }) => (
<div data-testid="credit-limit-error">{message}</div>
),
}));
// Mock EntitlementError component
vi.mock("@/components/chat/EntitlementError", () => ({
default: ({ message }: { message: string }) => <div data-testid="entitlement-error">{message}</div>,
}))
default: ({ message }: { message: string }) => (
<div data-testid="entitlement-error">{message}</div>
),
}));
vi.mock("@/services/grpc-client", () => ({
AccountServiceClient: {
setUserOrganization: mockSetUserOrganization,
},
}));
// Mock ClineError
vi.mock("../../../../src/services/error/ClineError", () => ({
@@ -34,8 +46,9 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
RateLimit: "rateLimit",
Auth: "auth",
Entitlement: "entitlement",
OrgClinePassRestriction: "orgClinePassRestriction",
},
}))
}));
describe("ErrorRow", () => {
const mockMessage: ClineMessage = {
@@ -43,40 +56,47 @@ describe("ErrorRow", () => {
type: "say",
say: "error",
text: "Test error message",
}
};
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
mockSetUserOrganization.mockResolvedValue({});
});
it("renders basic error message", () => {
render(<ErrorRow errorType="error" message={mockMessage} />)
render(<ErrorRow errorType="error" message={mockMessage} />);
expect(screen.getByText("Test error message")).toBeInTheDocument()
})
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("renders mistake limit reached error", () => {
const mistakeMessage = { ...mockMessage, text: "Mistake limit reached" }
render(<ErrorRow errorType="mistake_limit_reached" message={mistakeMessage} />)
const mistakeMessage = { ...mockMessage, text: "Mistake limit reached" };
render(
<ErrorRow errorType="mistake_limit_reached" message={mistakeMessage} />,
);
expect(screen.getByText("Mistake limit reached")).toBeInTheDocument()
})
expect(screen.getByText("Mistake limit reached")).toBeInTheDocument();
});
it("renders diff error", () => {
render(<ErrorRow errorType="diff_error" message={mockMessage} />)
render(<ErrorRow errorType="diff_error" message={mockMessage} />);
expect(
screen.getByText("The model used search patterns that don't match anything in the file. Retrying..."),
).toBeInTheDocument()
})
screen.getByText(
"The model used search patterns that don't match anything in the file. Retrying...",
),
).toBeInTheDocument();
});
it("renders clineignore error", () => {
const clineignoreMessage = { ...mockMessage, text: "/path/to/file.txt" }
render(<ErrorRow errorType="clineignore_error" message={clineignoreMessage} />)
const clineignoreMessage = { ...mockMessage, text: "/path/to/file.txt" };
render(
<ErrorRow errorType="clineignore_error" message={clineignoreMessage} />,
);
expect(screen.getByText(/Cline tried to access/)).toBeInTheDocument()
expect(screen.getByText("/path/to/file.txt")).toBeInTheDocument()
})
expect(screen.getByText(/Cline tried to access/)).toBeInTheDocument();
expect(screen.getByText("/path/to/file.txt")).toBeInTheDocument();
});
describe("API error handling", () => {
it("renders credit limit error when balance error is detected", async () => {
@@ -92,16 +112,26 @@ describe("ErrorRow", () => {
buy_credits_url: "https://app.cline.bot/dashboard",
},
},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(<ErrorRow apiRequestFailedMessage="Insufficient credits error" errorType="error" message={mockMessage} />)
render(
<ErrorRow
apiRequestFailedMessage="Insufficient credits error"
errorType="error"
message={mockMessage}
/>,
);
expect(screen.getByTestId("credit-limit-error")).toBeInTheDocument()
expect(screen.getByText("You have run out of credits.")).toBeInTheDocument()
})
expect(screen.getByTestId("credit-limit-error")).toBeInTheDocument();
expect(
screen.getByText("You have run out of credits."),
).toBeInTheDocument();
});
it("renders rate limit error with request ID", async () => {
const mockClineError = {
@@ -110,46 +140,66 @@ describe("ErrorRow", () => {
_error: {
request_id: "req_123456",
},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(<ErrorRow apiRequestFailedMessage="Rate limit exceeded" errorType="error" message={mockMessage} />)
render(
<ErrorRow
apiRequestFailedMessage="Rate limit exceeded"
errorType="error"
message={mockMessage}
/>,
);
expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument()
expect(screen.getByText("Request ID: req_123456")).toBeInTheDocument()
})
expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument();
expect(screen.getByText("Request ID: req_123456")).toBeInTheDocument();
});
it("renders quota exceeded error", async () => {
const mockClineError = {
message: "Inference cap reached",
isErrorType: vi.fn((type) => type === "quotaexceeded"),
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(<ErrorRow apiRequestFailedMessage="The message" errorType="error" message="" />)
expect(screen.getByText("Inference cap reached")).toBeInTheDocument()
})
render(
<ErrorRow
apiRequestFailedMessage="The message"
errorType="error"
message=""
/>,
);
expect(screen.getByText("Inference cap reached")).toBeInTheDocument();
});
it("renders entitlement error with the detail message instead of a raw JSON blob", async () => {
const mockClineError = {
message: "403 Error 403: the user is not subscribed to required model plan",
message:
"403 Error 403: the user is not subscribed to required model plan",
isErrorType: vi.fn((type) => type === "entitlement"),
providerId: "cline-pass",
_error: {
code: "ENTITLEMENT_ERROR",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
message:
"Error 403: the user is not subscribed to required model plan",
},
},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
@@ -157,14 +207,64 @@ describe("ErrorRow", () => {
errorType="error"
message={mockMessage}
/>,
)
);
// Renders the friendly EntitlementError component with the human-readable detail message...
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument()
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument();
expect(
screen.getByText(
"Error 403: the user is not subscribed to required model plan",
),
).toBeInTheDocument();
// ...and does not dump the raw JSON blob or the [CLINE-PASS] ENTITLEMENT_ERROR header.
expect(screen.queryByText(/ENTITLEMENT_ERROR/)).not.toBeInTheDocument()
})
expect(screen.queryByText(/ENTITLEMENT_ERROR/)).not.toBeInTheDocument();
});
it("renders organization account ClinePass restriction with friendly account switching copy", async () => {
const rawMessage =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const mockClineError = {
message: rawMessage,
isErrorType: vi.fn((type) => type === "orgClinePassRestriction"),
providerId: "cline",
_error: {
code: "ENTITLEMENT_ERROR",
message: rawMessage,
},
};
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
apiRequestFailedMessage={rawMessage}
errorType="error"
message={mockMessage}
/>,
);
expect(
screen.getByTestId("org-cline-pass-restriction-error"),
).toBeInTheDocument();
expect(
screen.getByText(
/Organization accounts cannot use ClinePass subscriptions/,
),
).toBeInTheDocument();
expect(screen.queryByText(rawMessage)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("Switch to personal account"));
await waitFor(() =>
expect(mockSetUserOrganization).toHaveBeenCalledWith({}),
);
expect(
screen.getByText("Switched to personal account"),
).toBeInTheDocument();
});
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
@@ -172,27 +272,42 @@ describe("ErrorRow", () => {
isErrorType: vi.fn((type) => type === "auth"),
providerId: "cline",
_error: {},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(<ErrorRow apiRequestFailedMessage="Authentication failed" errorType="error" message={mockMessage} />)
render(
<ErrorRow
apiRequestFailedMessage="Authentication failed"
errorType="error"
message={mockMessage}
/>,
);
expect(screen.queryByText("Authentication failed")).not.toBeInTheDocument()
expect(screen.getByText(/Whoops looks like you're logged out/)).toBeInTheDocument()
expect(screen.getByText("Sign in to Cline")).toBeInTheDocument()
})
expect(
screen.queryByText("Authentication failed"),
).not.toBeInTheDocument();
expect(
screen.getByText(/Whoops looks like you're logged out/),
).toBeInTheDocument();
expect(screen.getByText("Sign in to Cline")).toBeInTheDocument();
});
it("renders PowerShell troubleshooting link when error mentions PowerShell", async () => {
const mockClineError = {
message: "PowerShell is not recognized as an internal or external command",
message:
"PowerShell is not recognized as an internal or external command",
isErrorType: vi.fn(() => false),
_error: {},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(
<ErrorRow
@@ -200,46 +315,66 @@ describe("ErrorRow", () => {
errorType="error"
message={mockMessage}
/>,
)
);
expect(screen.getByText(/PowerShell is not recognized/)).toBeInTheDocument()
expect(screen.getByText("troubleshooting guide")).toBeInTheDocument()
expect(screen.getByRole("link", { name: "troubleshooting guide" })).toHaveAttribute(
expect(
screen.getByText(/PowerShell is not recognized/),
).toBeInTheDocument();
expect(screen.getByText("troubleshooting guide")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "troubleshooting guide" }),
).toHaveAttribute(
"href",
"https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22",
)
})
);
});
it("handles apiReqStreamingFailedMessage instead of apiRequestFailedMessage", async () => {
const mockClineError = {
message: "Streaming failed",
isErrorType: vi.fn(() => false),
_error: {},
}
};
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any);
render(<ErrorRow apiReqStreamingFailedMessage="Streaming failed" errorType="error" message={mockMessage} />)
render(
<ErrorRow
apiReqStreamingFailedMessage="Streaming failed"
errorType="error"
message={mockMessage}
/>,
);
expect(screen.getByText("Streaming failed")).toBeInTheDocument()
})
expect(screen.getByText("Streaming failed")).toBeInTheDocument();
});
it("falls back to regular error message when ClineError.parse returns null", async () => {
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(undefined)
const { ClineError } = await import(
"../../../../src/services/error/ClineError"
);
vi.mocked(ClineError.parse).mockReturnValue(undefined);
render(<ErrorRow apiRequestFailedMessage="Some API error" errorType="error" message={mockMessage} />)
render(
<ErrorRow
apiRequestFailedMessage="Some API error"
errorType="error"
message={mockMessage}
/>,
);
// When ClineError.parse returns null, we display the raw error message for non-Cline providers
// Since clineError is undefined, isClineProvider is false, so we show the raw apiRequestFailedMessage
expect(screen.getByText("Some API error")).toBeInTheDocument()
})
expect(screen.getByText("Some API error")).toBeInTheDocument();
});
it("renders regular error message when no API error messages are provided", () => {
render(<ErrorRow errorType="error" message={mockMessage} />)
render(<ErrorRow errorType="error" message={mockMessage} />);
expect(screen.getByText("Test error message")).toBeInTheDocument()
})
})
})
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
});
});
@@ -1,176 +1,228 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { isClineProvider } from "@shared/utils/cline"
import { memo } from "react"
import CreditLimitError from "@/components/chat/CreditLimitError"
import EntitlementError from "@/components/chat/EntitlementError"
import SpendLimitError from "@/components/chat/SpendLimitError"
import { Button } from "@/components/ui/button"
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
import type { ClineMessage } from "@shared/ExtensionMessage";
import { isClineProvider } from "@shared/utils/cline";
import { memo } from "react";
import CreditLimitError from "@/components/chat/CreditLimitError";
import EntitlementError from "@/components/chat/EntitlementError";
import OrgClinePassRestrictionError from "@/components/chat/OrgClinePassRestrictionError";
import SpendLimitError from "@/components/chat/SpendLimitError";
import { Button } from "@/components/ui/button";
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext";
import {
ClineError,
ClineErrorType,
} from "../../../../src/services/error/ClineError";
const _errorColor = "var(--vscode-errorForeground)"
const _errorColor = "var(--vscode-errorForeground)";
interface ErrorRowProps {
message: ClineMessage
errorType: "error" | "mistake_limit_reached" | "diff_error" | "clineignore_error"
apiRequestFailedMessage?: string
apiReqStreamingFailedMessage?: string
message: ClineMessage;
errorType:
| "error"
| "mistake_limit_reached"
| "diff_error"
| "clineignore_error";
apiRequestFailedMessage?: string;
apiReqStreamingFailedMessage?: string;
}
const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStreamingFailedMessage }: ErrorRowProps) => {
const { clineUser } = useClineAuth()
const rawApiError = apiRequestFailedMessage || apiReqStreamingFailedMessage
const ErrorRow = memo(
({
message,
errorType,
apiRequestFailedMessage,
apiReqStreamingFailedMessage,
}: ErrorRowProps) => {
const { clineUser } = useClineAuth();
const rawApiError = apiRequestFailedMessage || apiReqStreamingFailedMessage;
const { isLoginLoading, handleSignIn } = useClineSignIn()
const { isLoginLoading, handleSignIn } = useClineSignIn();
const renderErrorContent = () => {
switch (errorType) {
case "error":
case "mistake_limit_reached":
// Handle API request errors with special error parsing
if (rawApiError) {
// FIXME: ClineError parsing should not be applied to non-Cline providers, but it seems we're using clineErrorMessage below in the default error display
const clineError = ClineError.parse(rawApiError)
const errorMessage = clineError?._error?.message || clineError?.message || rawApiError
const requestId = clineError?._error?.request_id
const providerId = clineError?.providerId || clineError?._error?.providerId
const errorCode = clineError?._error?.code
const renderErrorContent = () => {
switch (errorType) {
case "error":
case "mistake_limit_reached":
// Handle API request errors with special error parsing
if (rawApiError) {
// FIXME: ClineError parsing should not be applied to non-Cline providers, but it seems we're using clineErrorMessage below in the default error display
const clineError = ClineError.parse(rawApiError);
const errorMessage =
clineError?._error?.message || clineError?.message || rawApiError;
const requestId = clineError?._error?.request_id;
const providerId =
clineError?.providerId || clineError?._error?.providerId;
const errorCode = clineError?._error?.code;
if (clineError?.isErrorType(ClineErrorType.Balance)) {
const errorDetails = clineError._error?.details
return (
<CreditLimitError
buyCreditsUrl={errorDetails?.buy_credits_url}
currentBalance={errorDetails?.current_balance}
message={errorDetails?.message}
totalPromotions={errorDetails?.total_promotions}
totalSpent={errorDetails?.total_spent}
/>
)
}
if (clineError?.isErrorType(ClineErrorType.Balance)) {
const errorDetails = clineError._error?.details;
return (
<CreditLimitError
buyCreditsUrl={errorDetails?.buy_credits_url}
currentBalance={errorDetails?.current_balance}
message={errorDetails?.message}
totalPromotions={errorDetails?.total_promotions}
totalSpent={errorDetails?.total_spent}
/>
);
}
if (clineError?.isErrorType(ClineErrorType.SpendLimit)) {
const d = clineError._error?.details
return (
<SpendLimitError
budgetPeriod={d?.budget_period}
limitUsd={d?.limit_usd}
message={d?.message || errorMessage}
resetsAt={d?.resets_at}
spentUsd={d?.spent_usd}
/>
)
}
if (clineError?.isErrorType(ClineErrorType.SpendLimit)) {
const d = clineError._error?.details;
return (
<SpendLimitError
budgetPeriod={d?.budget_period}
limitUsd={d?.limit_usd}
message={d?.message || errorMessage}
resetsAt={d?.resets_at}
spentUsd={d?.spent_usd}
/>
);
}
if (clineError?.isErrorType(ClineErrorType.Entitlement)) {
const detailMessage = clineError?._error?.details?.message || errorMessage
return <EntitlementError message={detailMessage} />
}
if (clineError?.isErrorType(ClineErrorType.Entitlement)) {
const detailMessage =
clineError?._error?.details?.message || errorMessage;
return <EntitlementError message={detailMessage} />;
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
{errorMessage}
{requestId && <div>Request ID: {requestId}</div>}
</p>
)
}
if (
clineError?.isErrorType(ClineErrorType.OrgClinePassRestriction)
) {
return <OrgClinePassRestrictionError />;
}
if (clineError?.isErrorType(ClineErrorType.QuotaExceeded)) {
const detailMessage = clineError?._error?.details?.message || errorMessage
return <p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">{detailMessage}</p>
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
{errorMessage}
{requestId && <div>Request ID: {requestId}</div>}
</p>
);
}
if (clineError?.isErrorType(ClineErrorType.Auth) && isClineProvider(providerId)) {
return !clineUser ? (
// User is using Cline provider and is not logged in
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center rounded border border-neutral-500/30 bg-vscode-editor-background p-6 text-center text-vscode-foreground">
Whoops looks like you're logged out click below to sign in
if (clineError?.isErrorType(ClineErrorType.QuotaExceeded)) {
const detailMessage =
clineError?._error?.details?.message || errorMessage;
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
{detailMessage}
</p>
);
}
if (
clineError?.isErrorType(ClineErrorType.Auth) &&
isClineProvider(providerId)
) {
return !clineUser ? (
// User is using Cline provider and is not logged in
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center rounded border border-neutral-500/30 bg-vscode-editor-background p-6 text-center text-vscode-foreground">
Whoops looks like you're logged out click below to sign in
</div>
<Button
className="w-full"
disabled={isLoginLoading}
onClick={handleSignIn}
>
Sign in to Cline
{isLoginLoading && (
<span className="ml-1 animate-spin">
<span className="codicon codicon-refresh" />
</span>
)}
</Button>
</div>
<Button className="w-full" disabled={isLoginLoading} onClick={handleSignIn}>
Sign in to Cline
{isLoginLoading && (
<span className="ml-1 animate-spin">
<span className="codicon codicon-refresh" />
</span>
) : (
// Don't show sign in button after the user has logged in, just ask them to retry
<div className="mt-4">
<span className="text-description">
(Click "Retry" below)
</span>
</div>
);
}
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere flex flex-col gap-3">
{/* Display the well-formatted error extracted from the ClineError instance */}
<header>
{providerId && (
<span className="uppercase">[{providerId}] </span>
)}
</Button>
</div>
) : (
// Don't show sign in button after the user has logged in, just ask them to retry
<div className="mt-4">
<span className="text-description">(Click "Retry" below)</span>
</div>
)
{errorCode && <span>{errorCode}</span>}
{errorMessage}
{requestId && <div>Request ID: {requestId}</div>}
</header>
{/* Windows Powershell Issue */}
{errorMessage?.toLowerCase()?.includes("powershell") && (
<div>
It seems like you're having Windows PowerShell issues,
please see this{" "}
<a
className="underline text-inherit"
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
>
troubleshooting guide
</a>
.
</div>
)}
{/* Display raw API error if different from parsed error message */}
{errorMessage !== rawApiError && <div>{rawApiError}</div>}
<div className="mt-4">
<span className="text-description">
(Click "Retry" below)
</span>
</div>
</p>
);
}
// Regular error message
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere flex flex-col gap-3">
{/* Display the well-formatted error extracted from the ClineError instance */}
<header>
{providerId && <span className="uppercase">[{providerId}] </span>}
{errorCode && <span>{errorCode}</span>}
{errorMessage}
{requestId && <div>Request ID: {requestId}</div>}
</header>
{/* Windows Powershell Issue */}
{errorMessage?.toLowerCase()?.includes("powershell") && (
<div>
It seems like you're having Windows PowerShell issues, please see this{" "}
<a
className="underline text-inherit"
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22">
troubleshooting guide
</a>
.
</div>
)}
{/* Display raw API error if different from parsed error message */}
{errorMessage !== rawApiError && <div>{rawApiError}</div>}
<div className="mt-4">
<span className="text-description">(Click "Retry" below)</span>
</div>
<p className="m-0 mt-0 whitespace-pre-wrap text-error wrap-anywhere">
{message.text}
</p>
)
}
);
// Regular error message
return <p className="m-0 mt-0 whitespace-pre-wrap text-error wrap-anywhere">{message.text}</p>
case "diff_error":
return (
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
</div>
)
case "clineignore_error":
return (
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
<div>
Cline tried to access <code>{message.text}</code> which is blocked by the <code>.clineignore</code>
file.
case "diff_error":
return (
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
<div>
The model used search patterns that don't match anything in the
file. Retrying...
</div>
</div>
</div>
)
);
default:
return null
case "clineignore_error":
return (
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-quote text-foreground">
<div>
Cline tried to access <code>{message.text}</code> which is
blocked by the <code>.clineignore</code>
file.
</div>
</div>
);
default:
return null;
}
};
// For diff_error and clineignore_error, we don't show the header separately
if (errorType === "diff_error" || errorType === "clineignore_error") {
return renderErrorContent();
}
}
// For diff_error and clineignore_error, we don't show the header separately
if (errorType === "diff_error" || errorType === "clineignore_error") {
return renderErrorContent()
}
// For other error types, show header + content
return renderErrorContent();
},
);
// For other error types, show header + content
return renderErrorContent()
})
export default ErrorRow
export default ErrorRow;
@@ -0,0 +1,62 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react";
import { useState } from "react";
import { AccountServiceClient } from "@/services/grpc-client";
const ORG_CLINE_PASS_RESTRICTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions.";
const OrgClinePassRestrictionError = () => {
const [isSwitching, setIsSwitching] = useState(false);
const [didSwitch, setDidSwitch] = useState(false);
const [error, setError] = useState<string | undefined>();
const handleSwitchToPersonalAccount = async () => {
setIsSwitching(true);
setError(undefined);
try {
await AccountServiceClient.setUserOrganization({});
setDidSwitch(true);
} catch (error) {
console.error("Failed to switch to personal Cline account:", error);
setError(
"Failed to switch account. Use /accounts to switch to your personal account.",
);
} finally {
setIsSwitching(false);
}
};
return (
<div
className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)"
data-testid="org-cline-pass-restriction-error"
>
<div className="text-error mb-2">
Organization account cannot use ClinePass
</div>
<div className="text-(--vscode-descriptionForeground) text-xs wrap-anywhere">
{ORG_CLINE_PASS_RESTRICTION_MESSAGE}
</div>
<VSCodeButton
className="w-full mt-3"
disabled={isSwitching || didSwitch}
onClick={handleSwitchToPersonalAccount}
>
{isSwitching
? "Switching..."
: didSwitch
? "Switched to personal account"
: "Switch to personal account"}
</VSCodeButton>
{didSwitch && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-2">
Retry the request after switching.
</div>
)}
{error && <div className="text-error text-xs mt-2">{error}</div>}
</div>
);
};
export { ORG_CLINE_PASS_RESTRICTION_MESSAGE };
export default OrgClinePassRestrictionError;
@@ -1,6 +1,6 @@
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
import { describe, expect, it } from "vitest"
import { getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
import { CLINEPASS_GROUP, getClineUIOnboardingGroups, getRecommendedModelsData } from "../data-models"
function model(id: string, group: string): OnboardingModel {
return {
@@ -22,7 +22,7 @@ describe("getClineUIOnboardingGroups", () => {
it("buckets ClinePass models into the clinePass group", () => {
const result = getClineUIOnboardingGroups(
groupOf([
model("cline-pass/glm-5.1", "clinepass"),
model("cline-pass/glm-5.1", CLINEPASS_GROUP),
model("free-model", "free"),
model("anthropic/claude", "frontier"),
model("z-ai/glm", "open source"),
@@ -30,12 +30,18 @@ describe("getClineUIOnboardingGroups", () => {
)
expect(result.clinePass).toHaveLength(1)
expect(result.clinePass[0].group).toBe("clinepass")
expect(result.clinePass[0].group).toBe(CLINEPASS_GROUP)
expect(result.clinePass[0].models.map((m) => m.id)).toEqual(["cline-pass/glm-5.1"])
expect(result.free[0].models.map((m) => m.id)).toEqual(["free-model"])
expect(result.power.flatMap((g) => g.models.map((m) => m.id))).toEqual(["anthropic/claude", "z-ai/glm"])
})
it("does not bucket cline-pass ids without a ClinePass group label", () => {
const result = getClineUIOnboardingGroups(groupOf([model("cline-pass/glm-5.2", "frontier")]))
expect(result.clinePass).toEqual([])
})
it("returns an empty clinePass group when no ClinePass models are present", () => {
const result = getClineUIOnboardingGroups(groupOf([model("free-model", "free")]))
expect(result.clinePass).toEqual([])
@@ -1,6 +1,8 @@
import type { ClineRecommendedModel, OpenRouterModelInfo } from "@shared/proto/cline/models"
import type { OnboardingModel, OnboardingModelGroup } from "@shared/proto/cline/state"
export const CLINEPASS_GROUP = "cline-pass"
export interface RecommendedModelsData {
recommended: ClineRecommendedModel[]
free: ClineRecommendedModel[]
@@ -39,16 +41,20 @@ interface ModelGroup {
models: OnboardingModel[]
}
function isClinePassOnboardingModel(model: OnboardingModel): boolean {
return model.group === CLINEPASS_GROUP
}
export function getClineUIOnboardingGroups(groupedModels: OnboardingModelGroup): OnboardingModelsByGroup {
const { models } = groupedModels
const clinePassModels = models.filter((m) => m.group === "clinepass")
const clinePassModels = models.filter(isClinePassOnboardingModel)
const freeModels = models.filter((m) => m.group === "free")
const frontierModels = models.filter((m) => m.group === "frontier")
const openSourceModels = models.filter((m) => m.group === "open source")
return {
clinePass: clinePassModels.length > 0 ? [{ group: "clinepass", models: clinePassModels }] : [],
clinePass: clinePassModels.length > 0 ? [{ group: CLINEPASS_GROUP, models: clinePassModels }] : [],
free: freeModels.length > 0 ? [{ group: "free", models: freeModels }] : [],
power: [
...(frontierModels.length > 0 ? [{ group: "frontier", models: frontierModels }] : []),
@@ -8,7 +8,7 @@ import { CLINE_PASS_FEATURE_FLAG } from "@/constants/featureFlags"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
import { CLINEPASS_GROUP, getRecommendedModelsData, type RecommendedModelsData } from "./data-models"
export type OnboardingModelsStatus = "loading" | "success" | "empty"
@@ -107,7 +107,7 @@ export function useOnboardingModels(): UseOnboardingModelsResult {
const clinePassCatalog = Object.fromEntries(
data.clinePass.map((rec) => [rec.id, resolveClinePassModelInfo(rec.id, openRouterModelsByName)]),
)
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, "clinepass", "", clinePassCatalog))
const clinePassModels = data.clinePass.map((rec) => toOnboardingModel(rec, CLINEPASS_GROUP, "", clinePassCatalog))
return { status: "success", models: { models: [...clinePassModels, ...freeModels, ...frontierModels] } }
}, [fetchState, modelCatalog, openRouterModelsByName])
@@ -4,6 +4,7 @@ import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
import { applyModelsDevProviderModels } from "@shared/models-dev"
import type { UserInfo } from "@shared/proto/cline/account"
import { EmptyRequest } from "@shared/proto/cline/common"
import type { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
@@ -359,6 +360,7 @@ export const ExtensionStateContextProvider: React.FC<{
if (response.stateJson) {
try {
const stateData = JSON.parse(response.stateJson) as ExtensionState
applyModelsDevProviderModels(stateData.modelsDevProviderModels)
setState((prevState) => {
// Versioning logic for autoApprovalSettings
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
+4 -4
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.28",
"version": "3.0.29",
"bin": {
"cline": "src/index.ts",
},
@@ -78,10 +78,9 @@
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^6.0.116",
@@ -92,6 +91,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
@@ -68,6 +68,41 @@ describe("AgentRuntime", () => {
expect(model.requests).toHaveLength(1);
});
it("does not persist an empty assistant message when the model stream fails", async () => {
const model = new ScriptedModel([
() => [{ type: "finish", reason: "error", error: "upstream failed" }],
]);
const addedMessages: AgentMessage[] = [];
const runtime = new AgentRuntime({ model });
runtime.subscribe((event) => {
if (event.type === "message-added") {
addedMessages.push(event.message);
}
});
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("upstream failed");
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.role).toBe("user");
expect(addedMessages.map((message) => message.role)).toEqual(["user"]);
});
it("does not complete or persist history when the model returns no content", async () => {
const model = new ScriptedModel([
() => [{ type: "finish", reason: "stop" }],
]);
const runtime = new AgentRuntime({ model });
const result = await runtime.run("Hi");
expect(result.status).toBe("failed");
expect(result.error?.message).toBe("Model returned empty response");
expect(result.messages).toHaveLength(1);
expect(result.messages[0]?.role).toBe("user");
});
it("executes a tool call and continues the loop", async () => {
const model = new ScriptedModel([
() => [
@@ -1068,6 +1103,66 @@ describe("AgentRuntime", () => {
});
});
it("stamps runtime identity metadata onto model requests", async () => {
const model = new ScriptedModel([
(request) => {
const metadata = request.options?.metadata as
| Record<string, unknown>
| undefined;
expect(metadata).toMatchObject({
sessionId: "session-runtime",
agentId: "agent-runtime",
conversationId: "conversation-runtime",
iteration: 1,
});
expect(typeof metadata?.runId).toBe("string");
return [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
sessionId: "session-runtime",
agentId: "agent-runtime",
conversationId: "conversation-runtime",
model,
});
await runtime.run("capture metadata");
expect(model.requests).toHaveLength(1);
});
it("does not synthesize session or conversation ids in model request metadata", async () => {
const model = new ScriptedModel([
(request) => {
const metadata = request.options?.metadata as
| Record<string, unknown>
| undefined;
expect(metadata).not.toHaveProperty("sessionId");
expect(metadata).not.toHaveProperty("conversationId");
expect(metadata).toMatchObject({
agentId: "agent-runtime",
iteration: 1,
});
expect(typeof metadata?.runId).toBe("string");
return [
{ type: "text-delta", text: "done" },
{ type: "finish", reason: "stop" },
];
},
]);
const runtime = new AgentRuntime({
agentId: "agent-runtime",
model,
});
await runtime.run("capture metadata");
expect(model.requests).toHaveLength(1);
});
it("preserves the existing system prompt when prepareTurn returns only messages", async () => {
const compactedMessage: AgentMessage = {
id: "msg_compacted",
+27 -9
View File
@@ -27,6 +27,8 @@ import {
captureSdkError,
estimateTokens,
mergeModelOptions,
omitUndefinedValues,
trimNonEmpty,
} from "@cline/shared";
import { nanoid } from "nanoid";
@@ -594,6 +596,21 @@ export class AgentRuntime {
});
const { message, finishReason } = await this.generateAssistantMessage();
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
if (message.content.length === 0) {
throw new Error(
finishReason === "error"
? (this.state.lastError ?? "Model stream failed")
: "Model returned empty response",
);
}
const toolCalls = message.content.filter(
(part: AgentMessagePart): part is AgentToolCallPart =>
part.type === "tool-call",
);
finalAssistantMessage = message;
this.state.messages.push(message);
await this.emit({
@@ -609,14 +626,6 @@ export class AgentRuntime {
finishReason,
});
if (finishReason === "aborted") {
throw this.normalizeAbortError();
}
const toolCalls = message.content.filter(
(part: AgentMessagePart): part is AgentToolCallPart =>
part.type === "tool-call",
);
if (finishReason === "error" && toolCalls.length === 0) {
throw new Error(this.state.lastError ?? "Model stream failed");
}
@@ -747,6 +756,13 @@ export class AgentRuntime {
finishReason: AgentModelFinishReason;
}> {
const usageBeforeModel = cloneUsage(this.state.usage);
const modelRequestMetadata = omitUndefinedValues({
sessionId: trimNonEmpty(this.config.sessionId),
agentId: this.state.agentId,
conversationId: trimNonEmpty(this.config.conversationId),
runId: this.state.runId,
iteration: this.state.iteration,
});
let request: AgentModelRequest = {
systemPrompt: this.config.systemPrompt,
messages: cloneMessages(this.state.messages),
@@ -756,7 +772,9 @@ export class AgentRuntime {
inputSchema: tool.inputSchema,
})),
signal: this.abortController?.signal,
options: this.config.modelOptions,
options: mergeModelOptions(this.config.modelOptions, {
metadata: modelRequestMetadata,
}),
};
if (this.state.iteration > 1) {
@@ -0,0 +1,259 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubCommandEnvelope } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __test__, handleConnectorCommand } from "./connector-handlers";
import type { HubTransportContext } from "./context";
describe("connector hub handlers", () => {
const previousDataDir = process.env.CLINE_DATA_DIR;
const tempRoots: string[] = [];
afterEach(() => {
process.env.CLINE_DATA_DIR = previousDataDir;
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function useTempDataDir(): string {
const root = mkdtempSync(join(tmpdir(), "hub-connectors-"));
tempRoots.push(root);
process.env.CLINE_DATA_DIR = root;
return root;
}
function createHubContext(
telemetry = { capture: vi.fn() },
): HubTransportContext {
return {
clients: new Map(),
sessionState: new Map(),
pendingApprovals: new Map(),
pendingCapabilityRequests: new Map(),
suppressNextTerminalEventBySession: new Map(),
telemetry: telemetry as never,
sessionHost: {} as never,
publish: vi.fn(),
buildEvent: vi.fn() as never,
requestCapability: vi.fn() as never,
};
}
function connectorCommand(
command: HubCommandEnvelope["command"],
payload?: Record<string, unknown>,
): HubCommandEnvelope {
return {
version: "v1",
requestId: `req-${command}`,
command,
payload,
};
}
function readPersistedConnectorValues(
channel: string,
): Record<string, string> {
const persisted = JSON.parse(
readFileSync(__test__.resolveConnectorSettingsPath(), "utf8"),
) as {
connectors: Record<string, { values: Record<string, string> }>;
};
return persisted.connectors[channel]?.values ?? {};
}
it("configures a connector through hub settings without starting it", () => {
useTempDataDir();
const response = __test__.configureConnector({
channel: "telegram",
values: { "-k": "123456:fake-token" },
security: { enabled: true, values: { userId: "123456789" } },
});
expect(response.active).toEqual([]);
expect(response.configured).toEqual([
expect.objectContaining({ id: "telegram", type: "telegram" }),
]);
const persisted = JSON.parse(
readFileSync(__test__.resolveConnectorSettingsPath(), "utf8"),
) as {
connectors: {
telegram: {
values: Record<string, string>;
security: { enabled: boolean; values: Record<string, string> };
};
};
};
expect(persisted.connectors.telegram.values["-k"]).toBe(
"123456:fake-token",
);
expect(persisted.connectors.telegram.security).toEqual({
enabled: true,
values: { userId: "123456789" },
});
});
it("validates security fields before persisting connector settings", () => {
useTempDataDir();
expect(() =>
__test__.configureConnector({
channel: "telegram",
values: { "-k": "123456:fake-token" },
security: { enabled: true, values: { userId: "not-a-number" } },
}),
).toThrow("Telegram user ID must contain digits only");
expect(__test__.connectorChannelsPayload().configured).toEqual([]);
});
it("deletes a connector config and removes an empty settings file", () => {
useTempDataDir();
__test__.configureConnector({
channel: "telegram",
values: { "-k": "123456:fake-token" },
});
__test__.configureConnector({
channel: "slack",
values: {
"--bot-token": "xoxb-token",
"--base-url": "",
"--app-token": "xapp-token",
},
});
const deleteTelegram = __test__.deleteConnectorConfig({
channel: "telegram",
});
expect(deleteTelegram.configured).toEqual([
expect.objectContaining({ id: "slack", type: "slack" }),
]);
const persisted = JSON.parse(
readFileSync(__test__.resolveConnectorSettingsPath(), "utf8"),
) as {
connectors: Record<string, unknown>;
};
expect(persisted.connectors).not.toHaveProperty("telegram");
expect(persisted.connectors).toHaveProperty("slack");
const deleteSlack = __test__.deleteConnectorConfig({ channel: "slack" });
expect(deleteSlack.configured).toEqual([]);
expect(existsSync(__test__.resolveConnectorSettingsPath())).toBe(false);
});
it("validates only included conditional connector fields", () => {
useTempDataDir();
expect(() =>
__test__.configureConnector({
channel: "slack",
values: {
"--bot-token": "xoxb-token",
"--base-url": "",
"--app-token": "xapp-token",
},
}),
).not.toThrow();
expect(() =>
__test__.configureConnector({
channel: "slack",
values: {
"--bot-token": "xoxb-token",
"--base-url": "https://example.com",
},
}),
).toThrow("Signing secret is required");
});
it("persists only active Slack fields for the selected mode", () => {
useTempDataDir();
__test__.configureConnector({
channel: "slack",
values: {
"--bot-token": "xoxb-token",
"--base-url": "",
"--app-token": "xapp-token",
"--signing-secret": "stale-signing-secret",
},
});
expect(readPersistedConnectorValues("slack")).toEqual({
"--bot-token": "xoxb-token",
"--base-url": "",
"--app-token": "xapp-token",
});
__test__.configureConnector({
channel: "slack",
values: {
"--bot-token": "xoxb-token",
"--base-url": "https://hooks.example.com",
"--signing-secret": "signing-secret",
"--app-token": "stale-app-token",
},
});
expect(readPersistedConnectorValues("slack")).toEqual({
"--bot-token": "xoxb-token",
"--base-url": "https://hooks.example.com",
"--signing-secret": "signing-secret",
});
});
it("emits telemetry for state-mutating connector command outcomes", async () => {
useTempDataDir();
const telemetry = { capture: vi.fn() };
const ctx = createHubContext(telemetry);
await handleConnectorCommand(
ctx,
connectorCommand("connector.configure", {
channel: "telegram",
values: { "-k": "123456:fake-token" },
}),
);
await handleConnectorCommand(
ctx,
connectorCommand("connector.delete_config", { channel: "telegram" }),
);
await handleConnectorCommand(
ctx,
connectorCommand("connector.configure", {
channel: "telegram",
values: {},
}),
);
await handleConnectorCommand(ctx, connectorCommand("connector.channels"));
expect(telemetry.capture).toHaveBeenCalledTimes(3);
expect(telemetry.capture).toHaveBeenNthCalledWith(1, {
event: "task.tool_used",
properties: {
ulid: "req-connector.configure",
tool: "connector.configure",
success: true,
},
});
expect(telemetry.capture).toHaveBeenNthCalledWith(2, {
event: "task.tool_used",
properties: {
ulid: "req-connector.delete_config",
tool: "connector.delete_config",
success: true,
},
});
expect(telemetry.capture).toHaveBeenNthCalledWith(3, {
event: "task.tool_used",
properties: {
ulid: "req-connector.configure",
tool: "connector.configure",
success: false,
},
});
});
});
@@ -0,0 +1,447 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import type {
ActiveConnectorRecord,
ConfiguredConnectorRecord,
ConnectorChannelsResponse,
ConnectorFieldDef,
ConnectorPlatformDef,
HubCommandEnvelope,
HubReplyEnvelope,
} from "@cline/shared";
import {
CONNECTOR_PLATFORMS,
connectorChannelsFromPlatforms,
listConnectorCatalog,
shouldIncludeConnectorField,
} from "@cline/shared";
import {
resolveConnectorDataDir,
resolveConnectorSettingsPath,
} from "@cline/shared/storage";
import { captureToolUsage } from "../../../services/telemetry/core-events";
import { errorReply, type HubTransportContext, okReply } from "./context";
type ConnectorSettingsEntry = {
type: string;
values: Record<string, string>;
security?: {
enabled: boolean;
values: Record<string, string>;
};
configuredAt: string;
updatedAt: string;
};
type ConnectorSettingsFile = {
version: 1;
connectors: Record<string, ConnectorSettingsEntry>;
};
type ConnectorFieldKey = keyof Omit<
ActiveConnectorRecord,
"id" | "type" | "pid" | "hubUrl"
>;
const CONNECTOR_SETTINGS_VERSION = 1;
const connectorFieldExtractors: Record<
ConnectorFieldKey,
(p: Record<string, unknown>) => string | number | undefined
> = {
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
port: (p) => (typeof p.port === "number" ? p.port : undefined),
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
connectionMode: (p) =>
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
botUsername: (p) =>
typeof p.botUsername === "string" ? p.botUsername : undefined,
applicationId: (p) =>
typeof p.applicationId === "string" ? p.applicationId : undefined,
phoneNumberId: (p) =>
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
};
const connectorActiveStateConfigs: Record<
string,
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
> = {
discord: {
required: ["userName", "applicationId"],
optional: ["startedAt", "port", "baseUrl"],
},
telegram: { required: ["botUsername"], optional: ["startedAt"] },
gchat: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
linear: {
required: ["userName"],
optional: ["startedAt", "port", "baseUrl"],
},
slack: {
required: ["userName"],
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
},
whatsapp: {
required: ["userName"],
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : undefined;
}
function readJsonRecord(path: string): Record<string, unknown> | undefined {
if (!existsSync(path)) {
return undefined;
}
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
}
function readConnectorSettings(): ConnectorSettingsFile {
const parsed = readJsonRecord(resolveConnectorSettingsPath());
const connectors = isRecord(parsed?.connectors) ? parsed.connectors : {};
const normalized: Record<string, ConnectorSettingsEntry> = {};
for (const [id, value] of Object.entries(connectors)) {
if (!isRecord(value)) {
continue;
}
const type = asString(value.type);
const configuredAt = asString(value.configuredAt);
const updatedAt = asString(value.updatedAt);
if (!type || !configuredAt || !updatedAt) {
continue;
}
const values = normalizeStringRecord(value.values);
const security = isRecord(value.security)
? {
enabled: value.security.enabled === true,
values: normalizeStringRecord(value.security.values),
}
: undefined;
normalized[id] = { type, values, security, configuredAt, updatedAt };
}
return { version: CONNECTOR_SETTINGS_VERSION, connectors: normalized };
}
function writeConnectorSettings(settings: ConnectorSettingsFile): void {
const path = resolveConnectorSettingsPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
}
function normalizeStringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) {
return {};
}
const entries: Record<string, string> = {};
for (const [key, raw] of Object.entries(value)) {
if (typeof raw === "string") {
entries[key] = raw.trim();
}
}
return entries;
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function listConnectorStatePaths(type: string): string[] {
const dir = join(resolveConnectorDataDir(), type);
if (!existsSync(dir)) {
return [];
}
return readdirSync(dir)
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
.map((name) => join(dir, name));
}
function connectorRecordId(
type: string,
fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
>,
pid: number,
): string {
const identity =
fields.botUsername ??
fields.userName ??
fields.applicationId ??
fields.phoneNumberId ??
String(pid);
return `${type}:${identity}`;
}
function readActiveConnectorRecord(
type: string,
statePath: string,
): ActiveConnectorRecord | undefined {
const parsed = readJsonRecord(statePath);
if (!parsed) {
return undefined;
}
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
const hubUrl =
typeof parsed.hubUrl === "string"
? parsed.hubUrl
: typeof parsed.rpcAddress === "string"
? parsed.rpcAddress
: undefined;
if (!pid || !hubUrl || !isProcessRunning(pid)) {
return undefined;
}
const config = connectorActiveStateConfigs[type];
if (!config) {
return undefined;
}
const fields: Partial<
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
> = {};
for (const key of config.required) {
const value = connectorFieldExtractors[key](parsed);
if (!value || (typeof value === "string" && !value.trim())) {
return undefined;
}
(fields as Record<string, unknown>)[key] = value;
}
for (const key of config.optional) {
const value = connectorFieldExtractors[key](parsed);
if (value !== undefined) {
(fields as Record<string, unknown>)[key] = value;
}
}
return {
id: connectorRecordId(type, fields, pid),
type,
pid,
hubUrl,
...fields,
} as ActiveConnectorRecord;
}
function listActiveConnectors(): ActiveConnectorRecord[] {
const records: ActiveConnectorRecord[] = [];
for (const { name } of listConnectorCatalog()) {
for (const statePath of listConnectorStatePaths(name)) {
const record = readActiveConnectorRecord(name, statePath);
if (record) {
records.push(record);
}
}
}
return records.sort((left, right) => {
if (left.type !== right.type) {
return left.type.localeCompare(right.type);
}
const leftName = left.botUsername ?? left.userName ?? "";
const rightName = right.botUsername ?? right.userName ?? "";
return leftName.localeCompare(rightName);
});
}
function listConfiguredConnectors(): ConfiguredConnectorRecord[] {
const settings = readConnectorSettings();
return Object.entries(settings.connectors)
.map(([id, entry]) => ({
id,
type: entry.type,
configuredAt: entry.configuredAt,
updatedAt: entry.updatedAt,
}))
.sort((left, right) => left.type.localeCompare(right.type));
}
function connectorChannelsPayload(): ConnectorChannelsResponse {
return {
available: connectorChannelsFromPlatforms(),
active: listActiveConnectors(),
configured: listConfiguredConnectors(),
};
}
function validateRequiredField(
field: ConnectorFieldDef,
values: Record<string, string>,
): void {
if (field.required && !values[field.flag]?.trim()) {
throw new Error(`${field.label} is required`);
}
}
function buildActiveConnectorFieldValues(
platform: ConnectorPlatformDef,
values: Record<string, string>,
): Record<string, string> {
const fieldValues: Record<string, string> = {};
for (const field of platform.fields) {
fieldValues[field.flag] = values[field.flag] ?? field.initialValue ?? "";
}
const activeFieldValues: Record<string, string> = {};
for (const field of platform.fields) {
if (!shouldIncludeConnectorField(field, fieldValues)) {
continue;
}
validateRequiredField(field, fieldValues);
activeFieldValues[field.flag] = fieldValues[field.flag];
}
return activeFieldValues;
}
function configureConnector(payload: unknown): ConnectorChannelsResponse {
if (!isRecord(payload)) {
throw new Error("connector.configure payload must be an object.");
}
const channel = asString(payload.channel);
if (!channel) {
throw new Error("channel is required");
}
const platform = CONNECTOR_PLATFORMS.find((entry) => entry.id === channel);
if (!platform) {
throw new Error(`unknown connector channel: ${channel}`);
}
const supported = new Set(listConnectorCatalog().map((entry) => entry.name));
if (!supported.has(platform.id)) {
throw new Error(`connector channel is not available: ${channel}`);
}
const values = normalizeStringRecord(payload.values);
const fieldValues = buildActiveConnectorFieldValues(platform, values);
const securityInput = isRecord(payload.security) ? payload.security : {};
const securityEnabled = securityInput.enabled === true;
const securityValues = normalizeStringRecord(securityInput.values);
if (securityEnabled && platform.security) {
for (const field of platform.security.fields) {
const value = securityValues[field.key];
if (!value) {
throw new Error(field.requiredMessage);
}
const validationError = field.validate?.(value);
if (validationError) {
throw new Error(validationError);
}
}
}
const settings = readConnectorSettings();
const now = new Date().toISOString();
const existing = settings.connectors[channel];
settings.connectors[channel] = {
type: channel,
values: fieldValues,
security: securityEnabled
? { enabled: true, values: securityValues }
: { enabled: false, values: {} },
configuredAt: existing?.configuredAt ?? now,
updatedAt: now,
};
writeConnectorSettings(settings);
return connectorChannelsPayload();
}
function deleteConnectorConfig(payload: unknown): ConnectorChannelsResponse {
if (!isRecord(payload)) {
throw new Error("connector.delete_config payload must be an object.");
}
const channel = asString(payload.channel);
if (!channel) {
throw new Error("channel is required");
}
const settings = readConnectorSettings();
delete settings.connectors[channel];
if (Object.keys(settings.connectors).length > 0) {
writeConnectorSettings(settings);
} else {
rmSync(resolveConnectorSettingsPath(), { force: true });
}
return connectorChannelsPayload();
}
function isStateMutatingConnectorCommand(
command: HubCommandEnvelope["command"],
) {
return (
command === "connector.configure" || command === "connector.delete_config"
);
}
function captureConnectorCommandUsage(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
success: boolean,
): void {
if (!isStateMutatingConnectorCommand(envelope.command)) {
return;
}
captureToolUsage(ctx.telemetry, {
ulid: envelope.sessionId ?? envelope.requestId ?? "hub",
tool: envelope.command,
success,
});
}
export async function handleConnectorCommand(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
try {
if (envelope.command === "connector.channels") {
return okReply(envelope, connectorChannelsPayload());
}
if (envelope.command === "connector.configure") {
const payload = configureConnector(envelope.payload);
captureConnectorCommandUsage(ctx, envelope, true);
return okReply(envelope, payload);
}
if (envelope.command === "connector.delete_config") {
const payload = deleteConnectorConfig(envelope.payload);
captureConnectorCommandUsage(ctx, envelope, true);
return okReply(envelope, payload);
}
return errorReply(
envelope,
"unsupported_connector_command",
`unsupported connector command: ${envelope.command}`,
);
} catch (error) {
captureConnectorCommandUsage(ctx, envelope, false);
return errorReply(
envelope,
"connector_command_failed",
error instanceof Error ? error.message : String(error),
);
}
}
export const __test__ = {
configureConnector,
connectorChannelsPayload,
deleteConnectorConfig,
resolveConnectorSettingsPath,
};
@@ -41,6 +41,7 @@ import {
handleClientUnregister,
handleClientUpdate,
} from "./handlers/client-handlers";
import { handleConnectorCommand } from "./handlers/connector-handlers";
import {
buildHubEvent,
type HubTransportContext,
@@ -397,6 +398,10 @@ export class HubServerTransport implements NativeHubTransport {
return await this.handleSettingsList(envelope);
case "settings.toggle":
return await this.handleSettingsToggle(envelope);
case "connector.channels":
case "connector.configure":
case "connector.delete_config":
return await handleConnectorCommand(this.ctx, envelope);
case "settings.get":
case "settings.patch":
return {
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { readBearerToken } from "./hub-websocket-server";
import {
isLocalHubHostName,
isLocalHubOrigin,
readBearerToken,
} from "./hub-websocket-server";
describe("readBearerToken", () => {
it("reads a bearer token with case-insensitive scheme", () => {
@@ -19,3 +23,19 @@ describe("readBearerToken", () => {
expect(readBearerToken("Basic token")).toBeNull();
});
});
describe("loopback websocket origin auth", () => {
it("recognizes loopback Hub hosts and browser origins", () => {
expect(isLocalHubHostName("127.0.0.1")).toBe(true);
expect(isLocalHubHostName("localhost")).toBe(true);
expect(isLocalHubHostName("::1")).toBe(true);
expect(isLocalHubOrigin("http://localhost:3000")).toBe(true);
expect(isLocalHubOrigin("http://127.0.0.1:3017")).toBe(true);
});
it("rejects non-loopback browser origins", () => {
expect(isLocalHubOrigin("https://example.com")).toBe(false);
expect(isLocalHubOrigin("http://192.168.1.10:3000")).toBe(false);
expect(isLocalHubOrigin(undefined)).toBe(false);
});
});
@@ -251,6 +251,32 @@ function readWebSocketAuthToken(
return null;
}
/** @internal Exported for websocket auth tests. */
export function isLocalHubHostName(value: string): boolean {
const normalized = value.trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "[::1]"
);
}
/** @internal Exported for websocket auth tests. */
export function isLocalHubOrigin(
value: string | string[] | undefined,
): boolean {
const raw = parseHeaderValue(value).trim();
if (!raw) {
return false;
}
try {
return isLocalHubHostName(new URL(raw).hostname);
} catch {
return false;
}
}
export async function startHubWebSocketServer(
options: HubWebSocketServerOptions,
): Promise<HubWebSocketServer> {
@@ -433,12 +459,13 @@ export async function startHubWebSocketServer(
socket.destroy();
return;
}
if (
!isValidHubAuthToken(
const isAuthorized =
isValidHubAuthToken(
readWebSocketAuthToken(request.headers["sec-websocket-protocol"]),
authToken,
)
) {
) ||
(isLocalHubHostName(host) && isLocalHubOrigin(request.headers.origin));
if (!isAuthorized) {
rejectUnauthorizedUpgradeSocket(socket);
return;
}
+4
View File
@@ -7,10 +7,14 @@
export * as Llms from "@cline/llms";
export {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/llms";
// Shared contracts and path helpers re-exported for app consumers.
export type {
@@ -14,17 +14,23 @@
* - `canStartRun` / `shutdown` guards enforce the lifecycle rules.
*/
import type { AgentRuntime, AgentRuntimeConfig } from "@cline/agents";
import type {
AgentConfig,
AgentEvent,
AgentExtension,
AgentExtensionContext,
AgentMessage,
AgentRunResult,
AgentRuntimeEvent,
AgentTool,
AgentToolContext,
import {
type AgentRuntime,
type AgentRuntimeConfig,
createAgentRuntime,
} from "@cline/agents";
import {
type AgentConfig,
type AgentEvent,
type AgentExtension,
type AgentExtensionContext,
type AgentMessage,
type AgentModel,
type AgentRunResult,
type AgentRuntimeEvent,
type AgentTool,
type AgentToolContext,
EMPTY_CONTENT_TEXT,
} from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import { CLINE_INTERNAL_TELEMETRY_METADATA_KEY } from "../../services/telemetry/tool-context";
@@ -1229,6 +1235,69 @@ describe("SessionRuntime.addTools / updateConnection / clearHistory / restore",
});
});
// ---------------------------------------------------------------------------
// real AgentRuntime smoke
// ---------------------------------------------------------------------------
describe("SessionRuntime real AgentRuntime smoke", () => {
it("does not replay ERROR: EMPTY CONTENT after an empty upstream failure", async () => {
const modelRequests: AgentMessage[][] = [];
const scriptedModel: AgentModel = {
async stream(request) {
modelRequests.push(request.messages.map((message) => ({ ...message })));
const turn = modelRequests.length;
return (async function* () {
if (turn === 1) {
yield {
type: "finish" as const,
reason: "error" as const,
error: "upstream failed",
};
return;
}
yield { type: "text-delta" as const, text: "second ok" };
yield { type: "finish" as const, reason: "stop" as const };
})();
},
};
const session = new SessionRuntime(
makeAgentConfig({
providerId: "cline",
modelId: "openai/gpt-5.5",
apiKey: "test-key",
}),
{
createAgentRuntimeImpl: (config) =>
createAgentRuntime({ ...config, model: scriptedModel }),
},
);
const first = await session.run("first");
expect(first.finishReason).toBe("error");
expect(first.text).toBe("upstream failed");
expect(session.getMessages().map((message) => message.role)).toEqual([
"user",
]);
const second = await session.continue("second");
expect(second.finishReason).toBe("completed");
expect(second.text).toBe("second ok");
expect(modelRequests).toHaveLength(2);
expect(
modelRequests[1]?.some((message) =>
message.content.some(
(part) => part.type === "text" && part.text === EMPTY_CONTENT_TEXT,
),
),
).toBe(false);
expect(modelRequests[1]?.map((message) => message.role)).toEqual([
"user",
"user",
]);
});
});
// ---------------------------------------------------------------------------
// shutdown
// ---------------------------------------------------------------------------
+4
View File
@@ -36,9 +36,13 @@ export type {
} from "./providers.browser";
export {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
normalizeProviderId,
} from "./providers.browser";
+4
View File
@@ -57,8 +57,10 @@ export {
BUILT_IN_PROVIDER,
BUILT_IN_PROVIDER_IDS,
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
createHandler,
createHandlerAsync,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClinePassSubscriptionUrl,
getRegisteredHandler,
@@ -67,6 +69,8 @@ export {
isBuiltInProviderId,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
isRegisteredHandlerAsync,
normalizeProviderId,
registerAsyncHandler,
@@ -1,9 +1,13 @@
export {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "./providers/errors";
export {
normalizeProviderId,
+4
View File
@@ -30,10 +30,14 @@ import {
export {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "./providers/errors";
export {
getRegisteredHandler,
+164 -1
View File
@@ -75,6 +75,165 @@ function buildCachedAiSdkMessages(
return aiMessages;
}
function resolveStickySession(
request: GatewayStreamRequest,
context: GatewayProviderContext,
):
| {
transport: "json-body" | "header";
field: string;
value: string;
}
| undefined {
const stickySession = context.provider.metadata?.stickySession;
if (!stickySession) {
return undefined;
}
const metadata = request.metadata;
const value =
metadata && typeof metadata === "object"
? metadata[stickySession.metadataKey]
: undefined;
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
return {
transport: stickySession.transport,
field: stickySession.field,
value: trimmed,
};
}
type FetchBodyText =
| { source: "init-body"; text: string }
| { request: Request; source: "request"; text: string };
async function bodyTextFromFetchInput(
input: Parameters<typeof fetch>[0],
init: Parameters<typeof fetch>[1],
): Promise<FetchBodyText | undefined> {
const body = init?.body;
if (body === null) {
return undefined;
}
if (typeof body === "string") {
return { source: "init-body", text: body };
}
if (body instanceof URLSearchParams) {
return { source: "init-body", text: body.toString() };
}
if (body instanceof ArrayBuffer) {
return { source: "init-body", text: Buffer.from(body).toString("utf8") };
}
if (ArrayBuffer.isView(body)) {
return {
source: "init-body",
text: Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString(
"utf8",
),
};
}
if (body !== undefined) {
return undefined;
}
if (input instanceof Request) {
try {
return {
request: input,
source: "request",
text: await input.clone().text(),
};
} catch {
return undefined;
}
}
return undefined;
}
async function injectJsonBodyStickySession(
input: Parameters<typeof fetch>[0],
init: Parameters<typeof fetch>[1],
stickySession: { field: string; value: string },
): Promise<Parameters<typeof fetch>> {
const bodyText = await bodyTextFromFetchInput(input, init);
if (!bodyText?.text.trim().startsWith("{")) {
return [input, init];
}
let parsed: unknown;
try {
parsed = JSON.parse(bodyText.text);
} catch {
return [input, init];
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return [input, init];
}
const body = parsed as Record<string, unknown>;
const existingValue = body[stickySession.field];
if (typeof existingValue !== "string" || !existingValue.trim()) {
body[stickySession.field] = stickySession.value;
}
const nextBody = JSON.stringify(body);
if (bodyText.source === "init-body") {
return [input, { ...init, body: nextBody }];
}
return [new Request(bodyText.request, { body: nextBody }), init];
}
function injectHeaderStickySession(
input: Parameters<typeof fetch>[0],
init: Parameters<typeof fetch>[1],
stickySession: { field: string; value: string },
): Parameters<typeof fetch> {
const headers = new Headers(
input instanceof Request ? input.headers : undefined,
);
new Headers(init?.headers).forEach((value, key) => {
headers.set(key, value);
});
if (!headers.get(stickySession.field)?.trim()) {
headers.set(stickySession.field, stickySession.value);
}
return [input, { ...init, headers }];
}
function wrapFetchForStickySession(
baseFetch: typeof fetch | undefined,
request: GatewayStreamRequest,
context: GatewayProviderContext,
): typeof fetch | undefined {
const stickySession = resolveStickySession(request, context);
if (!stickySession) {
return baseFetch;
}
const delegate = baseFetch ?? globalThis.fetch;
if (!delegate) {
return baseFetch;
}
const sessionFetch = (async (input, init) => {
const [nextInput, nextInit] =
stickySession.transport === "json-body"
? await injectJsonBodyStickySession(input, init, stickySession)
: injectHeaderStickySession(input, init, stickySession);
return delegate(nextInput, nextInit);
}) as typeof fetch;
const delegateWithPreconnect = delegate as typeof fetch & {
preconnect?: (...args: unknown[]) => unknown;
};
if (typeof delegateWithPreconnect.preconnect === "function") {
(
sessionFetch as typeof fetch & {
preconnect?: (...args: unknown[]) => unknown;
}
).preconnect = delegateWithPreconnect.preconnect.bind(delegate);
}
return sessionFetch;
}
async function ensureGatewayLangfuseTelemetry(
providerId: string,
): Promise<boolean> {
@@ -892,7 +1051,11 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
kind,
{
...config,
fetch: wrapFetchForProviderRequestCapture(config.fetch, request),
fetch: wrapFetchForStickySession(
wrapFetchForProviderRequestCapture(config.fetch, request),
request,
context,
),
},
context,
);
+47 -15
View File
@@ -21,7 +21,12 @@ import type {
ProviderClient,
ProviderProtocol,
} from "../catalog/types";
import { ClineNotSubscribedError, isClineNotSubscribedMessage } from "./errors";
import {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "./errors";
import { filterOpenAICodexModels } from "./openai-codex-models";
import {
ANTHROPIC_AND_QWEN_CACHE_ROUTING_METADATA,
@@ -38,6 +43,13 @@ export const DEFAULT_EXTERNAL_OCA_BASE_URL =
const CLINE_DEFAULT_MODEL_ID = "anthropic/claude-sonnet-4.6";
const CLINE_PASS_PROVIDER_ID = "cline-pass";
const OPENAI_CODEX_DEFAULT_MODEL_ID = "gpt-5.4";
const OPENROUTER_STICKY_SESSION_METADATA: GatewayProviderMetadata = {
stickySession: {
transport: "json-body",
field: "session_id",
metadataKey: "sessionId",
},
};
export type ProviderFamily =
| "openai"
@@ -504,12 +516,41 @@ function createClineLikeSpec(
};
}
async function handleClineResponseError(
response: Response,
providerId: string,
): Promise<void> {
if (response.status !== 403) {
return;
}
const body = await response
.clone()
.text()
.catch(() => "");
if (isClineOrgIndividualInferenceSubscriptionMessage(body)) {
throw new ClineOrgIndividualInferenceSubscriptionError(providerId);
}
if (isClineNotSubscribedMessage(body)) {
throw new ClineNotSubscribedError(providerId);
}
}
const cline = createClineLikeSpec({
id: "cline",
name: "Cline",
popular: 1,
modelsFactory: buildClineModels,
defaultModelId: CLINE_DEFAULT_MODEL_ID,
defaults: {
options: {
onResponseError: async (response: Response) => {
await handleClineResponseError(response, "cline");
},
},
},
});
const clinePass = createClineLikeSpec({
@@ -523,19 +564,7 @@ const clinePass = createClineLikeSpec({
defaults: {
options: {
onResponseError: async (response: Response) => {
if (response.status !== 403) {
return undefined;
}
const body = await response
.clone()
.text()
.catch(() => "");
if (isClineNotSubscribedMessage(body)) {
throw new ClineNotSubscribedError(CLINE_PASS_PROVIDER_ID);
}
return undefined;
await handleClineResponseError(response, CLINE_PASS_PROVIDER_ID);
},
},
},
@@ -867,7 +896,10 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
modelsProviderId: "openrouter",
docsUrl: "https://openrouter.ai/models",
defaults: { baseUrl: "https://openrouter.ai/api/v1" },
metadata: ANTHROPIC_AND_QWEN_CACHE_ROUTING_METADATA,
metadata: {
...ANTHROPIC_AND_QWEN_CACHE_ROUTING_METADATA,
...OPENROUTER_STICKY_SESSION_METADATA,
},
},
{
id: "ollama",
+32 -2
View File
@@ -2,12 +2,14 @@ import { getClineEnvironmentConfig } from "@cline/shared";
export const CLINE_NOT_SUBSCRIBED_RESPONSE_MESSAGE =
"the user is not subscribed to required model plan";
export const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_RESPONSE_MESSAGE =
"organization accounts cannot use individual model inference subscriptions";
export function getClinePassSubscriptionUrl(): string {
return `${new URL(
"/dashboard/subscription",
"/dashboard/subscription?personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}/`;
).toString()}`;
}
export function getClineNotSubscribedMessage(): string {
@@ -24,12 +26,40 @@ export class ClineNotSubscribedError extends Error {
}
}
export function getClineOrgIndividualInferenceSubscriptionMessage(): string {
return "Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
}
export class ClineOrgIndividualInferenceSubscriptionError extends Error {
public readonly providerId?: string;
constructor(providerId?: string) {
super(getClineOrgIndividualInferenceSubscriptionMessage());
this.name = "ClineOrgIndividualInferenceSubscriptionError";
this.providerId = providerId;
}
}
export function isClineNotSubscribedError(
error: unknown,
): error is ClineNotSubscribedError {
return error instanceof ClineNotSubscribedError;
}
export function isClineOrgIndividualInferenceSubscriptionError(
error: unknown,
): error is ClineOrgIndividualInferenceSubscriptionError {
return error instanceof ClineOrgIndividualInferenceSubscriptionError;
}
export function isClineNotSubscribedMessage(text: string): boolean {
return text.toLowerCase().includes(CLINE_NOT_SUBSCRIBED_RESPONSE_MESSAGE);
}
export function isClineOrgIndividualInferenceSubscriptionMessage(
text: string,
): boolean {
return text
.toLowerCase()
.includes(CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_RESPONSE_MESSAGE);
}
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
ClineNotSubscribedError,
ClineOrgIndividualInferenceSubscriptionError,
getClineOrgIndividualInferenceSubscriptionMessage,
getClineNotSubscribedMessage,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "./errors";
import { extractErrorMessage } from "./format";
@@ -69,3 +72,30 @@ describe("ClineNotSubscribedError", () => {
);
});
});
describe("ClineOrgIndividualInferenceSubscriptionError", () => {
it("uses the user-facing organization account message", () => {
expect(
new ClineOrgIndividualInferenceSubscriptionError("cline").message,
).toBe(getClineOrgIndividualInferenceSubscriptionMessage());
});
it("detects the organization individual-subscription entitlement message", () => {
expect(
isClineOrgIndividualInferenceSubscriptionMessage(
JSON.stringify({
error: {
code: "ENTITLEMENT_ERROR",
message:
"organization accounts cannot use individual model inference subscriptions",
},
}),
),
).toBe(true);
expect(
isClineOrgIndividualInferenceSubscriptionMessage(
"the user is not subscribed to required model plan",
),
).toBe(false);
});
});
@@ -38,6 +38,19 @@ const codexExecSpy = vi.fn((modelId: string) => ({
family: "openai-codex",
}));
function createFetchMock() {
const fetchMock = vi.fn(
async (
_input: Parameters<typeof fetch>[0],
_init?: Parameters<typeof fetch>[1],
) => new Response("ok"),
);
return {
fetchMock,
fetch: fetchMock as unknown as typeof fetch,
};
}
vi.mock("ai", () => ({
jsonSchema: (schema: unknown, options: unknown) => ({
jsonSchema: schema,
@@ -424,6 +437,11 @@ describe("sdk-gateway", () => {
const openrouter = gateway
.listProviders()
.find((provider) => provider.id === "openrouter");
expect(openrouter?.metadata?.stickySession).toEqual({
transport: "json-body",
field: "session_id",
metadataKey: "sessionId",
});
const promptCacheRoutes =
openrouter?.metadata?.routing?.promptCache?.routes ?? [];
@@ -3845,6 +3863,355 @@ describe("sdk-gateway", () => {
expect(config.fetch).toBe(customFetch);
});
it("adds OpenRouter session_id to JSON wire requests from request metadata", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{ providerId: "openrouter", apiKey: "test-key", fetch: customFetch },
],
});
await collect(
await gateway.stream({
providerId: "openrouter",
modelId: "anthropic/claude-test",
messages: baseMessages,
metadata: {
sessionId: "session-openrouter",
conversationId: "conversation-openrouter",
},
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
expect(config.fetch).not.toBe(customFetch);
await config.fetch?.("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(customFetch).toHaveBeenCalledOnce();
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(String(init?.body))).toMatchObject({
session_id: "session-openrouter",
});
});
it("preserves default model metadata when request metadata is present", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{ providerId: "openrouter", apiKey: "test-key", fetch: customFetch },
],
});
const model = gateway.createAgentModel(
{
providerId: "openrouter",
modelId: "anthropic/claude-test",
},
{
metadata: {
sessionId: "default-session",
traceId: "default-trace",
},
},
);
await collect(
await model.stream({
messages: baseMessages,
tools: [],
options: {
metadata: {
runId: "request-run",
traceId: "request-trace",
},
},
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
expect(config.fetch).not.toBe(customFetch);
await config.fetch?.("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(customFetch).toHaveBeenCalledOnce();
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(String(init?.body))).toMatchObject({
session_id: "default-session",
});
});
it("adds configured JSON-body sticky session fields for providers that opt in", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "openai-compatible",
apiKey: "test-key",
fetch: customFetch,
metadata: {
stickySession: {
transport: "json-body",
field: "sticky_session",
metadataKey: "sessionId",
},
},
},
],
});
await collect(
await gateway.stream({
providerId: "openai-compatible",
modelId: "custom/model",
messages: baseMessages,
metadata: { sessionId: "session-longer-than-eight" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
expect(config.fetch).not.toBe(customFetch);
await config.fetch?.("https://example.test/v1/chat/completions", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(customFetch).toHaveBeenCalledOnce();
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(String(init?.body))).toMatchObject({
sticky_session: "session-longer-than-eight",
});
});
it("adds configured header sticky session fields for providers that opt in", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "openai-compatible",
apiKey: "test-key",
fetch: customFetch,
metadata: {
stickySession: {
transport: "header",
field: "x-session-id",
metadataKey: "sessionId",
},
},
},
],
});
await collect(
await gateway.stream({
providerId: "openai-compatible",
modelId: "custom/model",
messages: baseMessages,
metadata: { sessionId: "session-header" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
expect(config.fetch).not.toBe(customFetch);
await config.fetch?.("https://example.test/v1/chat/completions", {
method: "POST",
headers: { "x-existing": "kept" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(customFetch).toHaveBeenCalledOnce();
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = new Headers(init?.headers);
expect(headers.get("x-session-id")).toBe("session-header");
expect(headers.get("x-existing")).toBe("kept");
});
it("preserves explicit configured header sticky session values", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{
providerId: "openai-compatible",
apiKey: "test-key",
fetch: customFetch,
metadata: {
stickySession: {
transport: "header",
field: "x-session-id",
metadataKey: "sessionId",
},
},
},
],
});
await collect(
await gateway.stream({
providerId: "openai-compatible",
modelId: "custom/model",
messages: baseMessages,
metadata: { sessionId: "session-header" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
await config.fetch?.("https://example.test/v1/chat/completions", {
method: "POST",
headers: { "x-session-id": "explicit-header-session" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(new Headers(init?.headers).get("x-session-id")).toBe(
"explicit-header-session",
);
});
it("preserves explicit OpenRouter session_id in JSON wire requests", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{ providerId: "openrouter", apiKey: "test-key", fetch: customFetch },
],
});
await collect(
await gateway.stream({
providerId: "openrouter",
modelId: "anthropic/claude-test",
messages: baseMessages,
metadata: { sessionId: "session-openrouter" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
await config.fetch?.("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
session_id: "explicit-session",
messages: [{ role: "user", content: "hi" }],
}),
});
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(JSON.parse(String(init?.body))).toMatchObject({
session_id: "explicit-session",
});
});
it("does not inspect a Request body when init explicitly sets a null body", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{ providerId: "openrouter", apiKey: "test-key", fetch: customFetch },
],
});
await collect(
await gateway.stream({
providerId: "openrouter",
modelId: "anthropic/claude-test",
messages: baseMessages,
metadata: { sessionId: "session-openrouter" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
const request = new Request(
"https://openrouter.ai/api/v1/chat/completions",
{
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
},
);
await config.fetch?.(request, { body: null });
expect(customFetch).toHaveBeenCalledOnce();
expect(customFetchMock.mock.calls[0]?.[0]).toBe(request);
const init = customFetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(init?.body).toBeNull();
});
it("does not fall back to conversationId for OpenRouter session_id", async () => {
const { fetchMock: customFetchMock, fetch: customFetch } =
createFetchMock();
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([{ type: "finish", finishReason: "stop" }]),
});
const gateway = createGateway({
providerConfigs: [
{ providerId: "openrouter", apiKey: "test-key", fetch: customFetch },
],
});
await collect(
await gateway.stream({
providerId: "openrouter",
modelId: "anthropic/claude-test",
messages: baseMessages,
metadata: { conversationId: "conversation-openrouter" },
}),
);
const config = openaiCompatibleFactorySpy.mock.calls[0]?.[0] as {
fetch?: typeof fetch;
};
expect(config.fetch).toBe(customFetch);
});
it("wraps provider fetch for wire capture while delegating to the configured fetch", async () => {
const captureDir = mkdtempSync(join(tmpdir(), "llms-wire-capture-"));
process.env.CLINE_CAPTURE_PROVIDER_REQUEST = "summary";
+16 -2
View File
@@ -21,6 +21,19 @@ export type * from "@cline/shared";
const GATEWAY_OUTPUT_RESERVE_TOKENS = 1_024;
function mergeRequestMetadata(
defaults: Record<string, unknown> | undefined,
request: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!defaults && !request) {
return undefined;
}
return {
...(defaults ?? {}),
...(request ?? {}),
};
}
export interface Gateway {
registerProvider(registration: GatewayProviderRegistration): this;
configureProvider(
@@ -92,9 +105,10 @@ class GatewayModelAdapter implements AgentModel {
maxTokens:
(request.options?.maxTokens as number | undefined) ??
this.defaults?.maxTokens,
metadata:
(request.options?.metadata as Record<string, unknown> | undefined) ??
metadata: mergeRequestMetadata(
this.defaults?.metadata,
request.options?.metadata as Record<string, unknown> | undefined,
),
reasoning:
requestedReasoning ?? legacyReasoning ?? this.defaults?.reasoning,
signal: request.signal ?? this.defaults?.signal,
@@ -0,0 +1,443 @@
export type ConnectorCatalogEntry = {
name: string;
description: string;
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
},
{
name: "gchat",
description: "Google Chat webhook bridge backed by RPC runtime sessions",
},
{
name: "linear",
description: "Linear webhook bridge backed by RPC runtime sessions",
},
{
name: "slack",
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
},
{
name: "telegram",
description: "Bridge Telegram bot messages into RPC chat sessions",
},
{
name: "whatsapp",
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
},
];
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
}
export interface ConnectorPlatformDef {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: ConnectorFieldDef[];
security?: ConnectorSecurityDef;
}
export interface ConnectorFieldDef {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: ConnectorFieldCondition;
}
export type ConnectorFieldCondition = {
flag: string;
equals?: string;
notEquals?: string;
};
export interface ConnectorSecurityFieldDef {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
validate?: (value: string) => string | undefined;
}
export interface ConnectorSecurityDef {
prompt: string;
fields: ConnectorSecurityFieldDef[];
buildArgs: (values: Record<string, string>) => string[];
}
export type ConnectorChannel = {
id: string;
name: string;
type: ConnectorPlatformDef["type"];
hint: string;
fields: ConnectorFieldDef[];
security?: {
prompt: string;
fields: Array<Omit<ConnectorSecurityFieldDef, "validate">>;
};
};
export type ActiveConnectorRecord = {
id: string;
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
connectionMode?: string;
};
export type ConfiguredConnectorRecord = {
id: string;
type: string;
configuredAt: string;
updatedAt: string;
};
export type ConnectorChannelsResponse = {
available: ConnectorChannel[];
active: ActiveConnectorRecord[];
configured: ConfiguredConnectorRecord[];
};
export function shouldIncludeConnectorField(
field: ConnectorFieldDef,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
export function connectorChannelsFromPlatforms(
platforms: ConnectorPlatformDef[] = CONNECTOR_PLATFORMS,
): ConnectorChannel[] {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
);
return platforms
.filter((platform) => supported.has(platform.id))
.map((platform) => ({
id: platform.id,
name: platform.name,
type: platform.type,
hint: platform.hint,
fields: platform.fields.map((field) => ({
flag: field.flag,
label: field.label,
placeholder: field.placeholder,
required: field.required,
help: field.help,
initialValue: field.initialValue,
options: field.options,
includeWhen: field.includeWhen,
})),
security: platform.security
? {
prompt: platform.security.prompt,
fields: platform.security.fields.map((field) => ({
key: field.key,
label: field.label,
placeholder: field.placeholder,
help: field.help,
requiredMessage: field.requiredMessage,
})),
}
: undefined,
}));
}
function validateTelegramUserId(value: string): string | undefined {
return /^\d+$/.test(value)
? undefined
: "Telegram user ID must contain digits only";
}
function validateSlackTeamId(value: string): string | undefined {
return /^T[A-Z0-9]+$/.test(value)
? undefined
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
}
function validateSlackUserId(value: string): string | undefined {
return /^[UW][A-Z0-9]+$/.test(value)
? undefined
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
}
export const CONNECTOR_PLATFORMS: ConnectorPlatformDef[] = [
{
id: "telegram",
name: "Telegram",
type: "polling",
hint: "Easiest to set up. No public URL needed.",
fields: [
{
flag: "-k",
label: "Bot token",
placeholder: "7123456789:AAH...",
required: true,
help: [
"Open Telegram and start a chat with @BotFather",
"Send /newbot and follow the prompts",
"BotFather gives you this after creating the bot",
"It looks like 7123456789:AAHxxx...",
],
},
],
security: {
prompt:
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
fields: [
{
key: "userId",
label: "Your Telegram user ID",
placeholder: "123456789",
help: [
"Message @userinfobot on Telegram",
"It will reply with your numeric user ID",
],
requiredMessage: "User ID is required to restrict access",
validate: validateTelegramUserId,
},
],
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
},
},
{
id: "slack",
name: "Slack",
type: "hybrid",
hint: "Public URL for webhook mode; leave blank for socket mode.",
fields: [
{
flag: "--bot-token",
label: "Bot token",
placeholder: "xoxb-...",
required: true,
help: [
"Go to api.slack.com/apps and create a new app",
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
"Install to workspace and copy the Bot Token",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "leave blank for socket mode",
help: [
"Enter a publicly accessible URL for webhook mode",
"Leave blank to use Slack socket mode instead",
],
},
{
flag: "--signing-secret",
label: "Signing secret",
required: true,
help: ["Found in your app's Basic Information page"],
includeWhen: { flag: "--base-url", notEquals: "" },
},
{
flag: "--app-token",
label: "App-level token",
placeholder: "xapp-...",
required: true,
help: [
"Enable Socket Mode in the Slack app",
"Generate an app-level token with the connections:write scope",
],
includeWhen: { flag: "--base-url", equals: "" },
},
],
security: {
prompt: "Restrict which Slack users can interact with the bot?",
fields: [
{
key: "teamId",
label: "Allowed Slack workspace ID",
placeholder: "T01ABC123",
help: [
"Open your Slack workspace URL in a browser",
"The workspace ID is the segment after /client/, for example T01ABC123",
],
requiredMessage: "Workspace ID is required to restrict access",
validate: validateSlackTeamId,
},
{
key: "userId",
label: "Allowed Slack member ID",
placeholder: "U01ABC123",
help: [
"Click a user's name in Slack, then View full profile",
"Click ... and Copy member ID",
],
requiredMessage: "Member ID is required to restrict access",
validate: validateSlackUserId,
},
],
buildArgs: ({ teamId, userId }) => [
"--hook-command",
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
],
},
},
{
id: "discord",
name: "Discord",
type: "webhook",
hint: "Requires a Discord app and public URL.",
fields: [
{
flag: "--application-id",
label: "Application ID",
required: true,
help: [
"Go to discord.com/developers/applications",
"Create a new app, copy the Application ID",
],
},
{
flag: "--bot-token",
label: "Bot token",
required: true,
help: ["Go to Bot section, create a bot, copy the token"],
},
{
flag: "--public-key",
label: "Public key",
required: true,
help: ["Found in General Information of your app"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
help: [
"Base URL for the connector",
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
],
},
],
},
{
id: "whatsapp",
name: "WhatsApp",
type: "webhook",
hint: "Requires Meta developer account and public URL.",
fields: [
{
flag: "--phone-number-id",
label: "Phone number ID",
required: true,
help: ["From your WhatsApp Business account in Meta Developer portal"],
},
{
flag: "--access-token",
label: "Access token",
required: true,
help: ["Generate a permanent token in Meta Developer portal"],
},
{
flag: "--app-secret",
label: "App secret",
required: true,
help: ["Found in App Settings > Basic"],
},
{
flag: "--verify-token",
label: "Webhook verify token",
placeholder: "my-verify-token",
required: true,
help: ["Any string you choose, used to verify webhook setup"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "gchat",
name: "Google Chat",
type: "webhook",
hint: "Requires Google Cloud project and public URL.",
fields: [
{
flag: "--credentials-json",
label: "Service account credentials JSON",
required: true,
help: [
"Create a service account in Google Cloud Console",
"Download the credentials JSON file",
"Paste the JSON content here",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "linear",
name: "Linear",
type: "webhook",
hint: "React to Linear issues and comments.",
fields: [
{
flag: "--api-key",
label: "API key",
required: true,
help: ["Go to Linear Settings > API > Personal API keys"],
},
{
flag: "--webhook-secret",
label: "Webhook signing secret",
required: true,
help: [
"Go to Settings > API > Webhooks, create one",
"Copy the signing secret",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
];
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
+3
View File
@@ -418,6 +418,9 @@ export type HubCommandName =
| "settings.get"
| "settings.patch"
| "settings.toggle"
| "connector.channels"
| "connector.configure"
| "connector.delete_config"
| "cron.event.ingest"
| "cron.event.list"
| "cron.event.get"
+21
View File
@@ -17,6 +17,25 @@ export {
ConnectorHookEventSchema,
} from "./connectors/events";
export type * from "./connectors/options";
export type {
ActiveConnectorRecord,
ConfiguredConnectorRecord,
ConnectorCatalogEntry,
ConnectorChannel,
ConnectorChannelsResponse,
ConnectorFieldCondition,
ConnectorFieldDef,
ConnectorPlatformDef,
ConnectorSecurityDef,
ConnectorSecurityFieldDef,
} from "./connectors/platforms";
export {
CONNECTOR_CATALOG,
CONNECTOR_PLATFORMS,
connectorChannelsFromPlatforms,
listConnectorCatalog,
shouldIncludeConnectorField,
} from "./connectors/platforms";
export type { AutomationEventEnvelope } from "./cron";
export type {
ClientContext,
@@ -193,10 +212,12 @@ export {
safeJsonParse,
safeJsonStringify,
} from "./parse/json";
export { omitUndefinedValues, type OmitUndefinedValues } from "./parse/object";
export { getDefaultShell, getShellArgs } from "./parse/shell";
export {
maskSecret,
sanitizeFileName,
trimNonEmpty,
truncateSplit,
truncateStr,
} from "./parse/string";
+21
View File
@@ -17,6 +17,25 @@ export {
ConnectorHookEventSchema,
} from "./connectors/events";
export type * from "./connectors/options";
export type {
ActiveConnectorRecord,
ConfiguredConnectorRecord,
ConnectorCatalogEntry,
ConnectorChannel,
ConnectorChannelsResponse,
ConnectorFieldCondition,
ConnectorFieldDef,
ConnectorPlatformDef,
ConnectorSecurityDef,
ConnectorSecurityFieldDef,
} from "./connectors/platforms";
export {
CONNECTOR_CATALOG,
CONNECTOR_PLATFORMS,
connectorChannelsFromPlatforms,
listConnectorCatalog,
shouldIncludeConnectorField,
} from "./connectors/platforms";
export type {
AutomationEventEnvelope,
CronEventSpec,
@@ -207,10 +226,12 @@ export {
safeJsonParse,
safeJsonStringify,
} from "./parse/json";
export { omitUndefinedValues, type OmitUndefinedValues } from "./parse/object";
export { getDefaultShell, getShellArgs } from "./parse/shell";
export {
maskSecret,
sanitizeFileName,
trimNonEmpty,
truncateSplit,
truncateStr,
} from "./parse/string";
+15
View File
@@ -62,14 +62,29 @@ export interface GatewayProviderRouting {
};
}
export type GatewayStickySessionTransport = "json-body" | "header";
export interface GatewayStickySessionMetadata {
/**
* Where the provider expects the sticky-session identifier on the wire.
* `field` is a JSON body property for `json-body`, and an HTTP header name
* for `header`.
*/
transport: GatewayStickySessionTransport;
field: string;
metadataKey: string;
}
export interface GatewayProviderMetadata {
promptCacheStrategy?: GatewayPromptCacheStrategy;
usageCostDisplay?: GatewayUsageCostDisplay;
routing?: GatewayProviderRouting;
stickySession?: GatewayStickySessionMetadata;
configFields?: readonly ProviderConfigField[];
[key: string]:
| JsonValue
| GatewayProviderRouting
| GatewayStickySessionMetadata
| readonly ProviderConfigField[]
| undefined;
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { omitUndefinedValues } from "./object";
describe("omitUndefinedValues", () => {
it("removes undefined properties and preserves other falsy values", () => {
expect(
omitUndefinedValues({
sessionId: "session-id",
conversationId: undefined,
count: 0,
enabled: false,
empty: "",
nullable: null,
}),
).toEqual({
sessionId: "session-id",
count: 0,
enabled: false,
empty: "",
nullable: null,
});
});
});
+24
View File
@@ -0,0 +1,24 @@
type KeysWithUndefinedValues<T extends Record<string, unknown>> = {
[K in keyof T]: undefined extends T[K] ? K : never;
}[keyof T];
type KeysWithoutUndefinedValues<T extends Record<string, unknown>> = Exclude<
keyof T,
KeysWithUndefinedValues<T>
>;
export type OmitUndefinedValues<T extends Record<string, unknown>> = Pick<
T,
KeysWithoutUndefinedValues<T>
> &
Partial<{
[K in KeysWithUndefinedValues<T>]: Exclude<T[K], undefined>;
}>;
export function omitUndefinedValues<T extends Record<string, unknown>>(
value: T,
): OmitUndefinedValues<T> {
return Object.fromEntries(
Object.entries(value).filter(([, entry]) => entry !== undefined),
) as OmitUndefinedValues<T>;
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { trimNonEmpty } from "./string";
describe("trimNonEmpty", () => {
it("returns trimmed strings and omits empty values", () => {
expect(trimNonEmpty(" session-id ")).toBe("session-id");
expect(trimNonEmpty(" ")).toBeUndefined();
expect(trimNonEmpty("")).toBeUndefined();
expect(trimNonEmpty(undefined)).toBeUndefined();
expect(trimNonEmpty(null)).toBeUndefined();
});
});
+7
View File
@@ -2,6 +2,13 @@ export function sanitizeFileName(value: string): string {
return value.toLowerCase().replace(/[^\w.-]+/g, "_");
}
export function trimNonEmpty(
value: string | null | undefined,
): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function truncateStr(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return `${str.slice(0, maxLen - 1)}`;
+3
View File
@@ -2,6 +2,7 @@ export { resolveExistingFilePath } from "./path-resolution";
export {
AGENT_CONFIG_DIRECTORY_NAME,
AGENTS_RULES_FILE_NAME,
CLINE_CONNECTOR_SETTINGS_FILE_NAME,
CLINE_MCP_SETTINGS_FILE_NAME,
type CronSpecsScope,
discoverPluginModulePaths,
@@ -17,6 +18,8 @@ export {
resolveClineDataDir,
resolveClineDir,
resolveConfiguredPluginModulePaths,
resolveConnectorDataDir,
resolveConnectorSettingsPath,
resolveCronDbPath,
resolveCronEventsDir,
resolveCronReportsDir,
@@ -2,11 +2,14 @@ import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
AGENT_CONFIG_DIRECTORY_NAME,
CLINE_CONNECTOR_SETTINGS_FILE_NAME,
CLINE_MCP_SETTINGS_FILE_NAME,
HOOKS_CONFIG_DIRECTORY_NAME,
RULES_CONFIG_DIRECTORY_NAME,
resolveAgentsConfigDirPath,
resolveClineDataDir,
resolveConnectorDataDir,
resolveConnectorSettingsPath,
resolveDbDataDir,
resolveGlobalAgentsRulesPath,
resolveGlobalSettingsPath,
@@ -22,6 +25,8 @@ import {
type EnvSnapshot = {
CLINE_DIR: string | undefined;
CLINE_DATA_DIR: string | undefined;
CLINE_CONNECTOR_DATA_DIR: string | undefined;
CLINE_CONNECTOR_SETTINGS_PATH: string | undefined;
CLINE_DB_DATA_DIR: string | undefined;
CLINE_GLOBAL_SETTINGS_PATH: string | undefined;
CLINE_MCP_SETTINGS_PATH: string | undefined;
@@ -34,6 +39,8 @@ function captureEnv(): EnvSnapshot {
return {
CLINE_DIR: process.env.CLINE_DIR,
CLINE_DATA_DIR: process.env.CLINE_DATA_DIR,
CLINE_CONNECTOR_DATA_DIR: process.env.CLINE_CONNECTOR_DATA_DIR,
CLINE_CONNECTOR_SETTINGS_PATH: process.env.CLINE_CONNECTOR_SETTINGS_PATH,
CLINE_DB_DATA_DIR: process.env.CLINE_DB_DATA_DIR,
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
@@ -45,6 +52,9 @@ function captureEnv(): EnvSnapshot {
function restoreEnv(snapshot: EnvSnapshot): void {
process.env.CLINE_DATA_DIR = snapshot.CLINE_DATA_DIR;
process.env.CLINE_CONNECTOR_DATA_DIR = snapshot.CLINE_CONNECTOR_DATA_DIR;
process.env.CLINE_CONNECTOR_SETTINGS_PATH =
snapshot.CLINE_CONNECTOR_SETTINGS_PATH;
process.env.CLINE_DIR = snapshot.CLINE_DIR;
process.env.CLINE_DB_DATA_DIR = snapshot.CLINE_DB_DATA_DIR;
process.env.CLINE_GLOBAL_SETTINGS_PATH = snapshot.CLINE_GLOBAL_SETTINGS_PATH;
@@ -85,6 +95,37 @@ describe("storage path resolution", () => {
expect(resolveTeamDataDir()).toBe(join("/tmp/cline-data", "teams"));
});
it("falls back to CLINE_DATA_DIR/connectors for connector storage", () => {
snapshot = captureEnv();
delete process.env.CLINE_CONNECTOR_DATA_DIR;
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveConnectorDataDir()).toBe(
join("/tmp/cline-data", "connectors"),
);
});
it("falls back to CLINE_DATA_DIR/connectors/settings.json for connector settings", () => {
snapshot = captureEnv();
delete process.env.CLINE_CONNECTOR_DATA_DIR;
delete process.env.CLINE_CONNECTOR_SETTINGS_PATH;
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
expect(resolveConnectorSettingsPath()).toBe(
join("/tmp/cline-data", "connectors", CLINE_CONNECTOR_SETTINGS_FILE_NAME),
);
});
it("uses CLINE_CONNECTOR_SETTINGS_PATH as-is when set", () => {
snapshot = captureEnv();
process.env.CLINE_CONNECTOR_SETTINGS_PATH =
"/tmp/cline-connectors/custom-settings.json";
expect(resolveConnectorSettingsPath()).toBe(
"/tmp/cline-connectors/custom-settings.json",
);
});
it("falls back to CLINE_DATA_DIR/db for sqlite storage", () => {
snapshot = captureEnv();
delete process.env.CLINE_DB_DATA_DIR;
+17
View File
@@ -24,6 +24,7 @@ export const PLUGINS_DIRECTORY_NAME = "plugins";
export const AGENTS_RULES_FILE_NAME = "AGENTS.md";
export const CLINE_MCP_SETTINGS_FILE_NAME = "cline_mcp_settings.json";
export const CLINE_CONNECTOR_SETTINGS_FILE_NAME = "settings.json";
function resolveDefaultHomeDir(): string {
const envHome = process?.env?.HOME?.trim();
@@ -144,6 +145,22 @@ export function resolveTeamDataDir(): string {
return join(resolveClineDataDir(), "teams");
}
export function resolveConnectorDataDir(): string {
const explicitDir = process.env.CLINE_CONNECTOR_DATA_DIR?.trim();
if (explicitDir) {
return explicitDir;
}
return join(resolveClineDataDir(), "connectors");
}
export function resolveConnectorSettingsPath(): string {
const explicitPath = process.env.CLINE_CONNECTOR_SETTINGS_PATH?.trim();
if (explicitPath) {
return explicitPath;
}
return join(resolveConnectorDataDir(), CLINE_CONNECTOR_SETTINGS_FILE_NAME);
}
export function resolveDbDataDir(): string {
const explicitDir = process.env.CLINE_DB_DATA_DIR?.trim();
if (explicitDir) {