mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb7619a18 | ||
|
|
f81afb51b5 | ||
|
|
864419d8b0 | ||
|
|
218db38544 | ||
|
|
a3b3295461 | ||
|
|
d09270940f | ||
|
|
23c9bf1c05 | ||
|
|
52531d935a | ||
|
|
19d4248381 | ||
|
|
b2b29678a2 | ||
|
|
ad30714608 | ||
|
|
9eebc44f24 | ||
|
|
6ddf48d227 | ||
|
|
ee59f81706 |
@@ -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
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -36,9 +36,13 @@ export type {
|
||||
} from "./providers.browser";
|
||||
export {
|
||||
ClineNotSubscribedError,
|
||||
ClineOrgIndividualInferenceSubscriptionError,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClineNotSubscribedMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
normalizeProviderId,
|
||||
} from "./providers.browser";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,10 +30,14 @@ import {
|
||||
|
||||
export {
|
||||
ClineNotSubscribedError,
|
||||
ClineOrgIndividualInferenceSubscriptionError,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClineNotSubscribedMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "./providers/errors";
|
||||
export {
|
||||
getRegisteredHandler,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}…`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user