mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36d1a6a8b3 | ||
|
|
7835260289 | ||
|
|
09f714be3f | ||
|
|
e56f8e2180 | ||
|
|
34040f4dfc | ||
|
|
d3e52c9265 | ||
|
|
f0023509b7 | ||
|
|
61d3f9c46e | ||
|
|
3ecf6b2264 | ||
|
|
98439ff250 | ||
|
|
de987a5246 | ||
|
|
90050426df | ||
|
|
205c5676ff | ||
|
|
1c13edd395 | ||
|
|
a2a1936709 | ||
|
|
35ce6a3f26 | ||
|
|
cf51936151 | ||
|
|
2c4aeae4f3 | ||
|
|
4077bb4693 | ||
|
|
0c027d2731 | ||
|
|
d430b24237 | ||
|
|
6cc93c124e | ||
|
|
fa3893e2a8 | ||
|
|
7e5b8be28c | ||
|
|
764e901693 | ||
|
|
2cabb2ddf6 | ||
|
|
c32789f697 | ||
|
|
349a8da750 | ||
|
|
f09dab7a0b | ||
|
|
162734782d | ||
|
|
3a3ea6ee96 | ||
|
|
1c1ea0bd53 | ||
|
|
70303d8541 | ||
|
|
1686e291a2 | ||
|
|
8ba15dfca6 | ||
|
|
b1bf80a961 | ||
|
|
c3a7a7097c | ||
|
|
36de5e4d8f | ||
|
|
696b72b708 | ||
|
|
92c039e202 | ||
|
|
933ead9d80 | ||
|
|
f43f4fdd01 | ||
|
|
d38a98d3e5 | ||
|
|
0012aef210 | ||
|
|
c623875b85 | ||
|
|
fbe6e3212a |
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: plugin-type
|
||||
id: cline-surface
|
||||
attributes:
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,6 +59,18 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Fable 5 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MiniMax M3 thinking controls across gateways.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clean up the Codex model list.
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
- Added a Cline credits refill link
|
||||
- Fixed scrolling for inline ask-question responses
|
||||
- Fixed connector thread session routing and stale hub session handling
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
- Fixed empty message content replay for Bedrock
|
||||
- Cleaned up the OpenAI Codex model list
|
||||
|
||||
## 3.0.20
|
||||
|
||||
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.23",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
+24
-59
@@ -1,11 +1,6 @@
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -82,18 +53,18 @@ async function performOAuthLogin(
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,37 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
parseAuthCommandArgs,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("parseAuthCommandArgs", () => {
|
||||
it("parses Azure API version quick setup option", () => {
|
||||
expect(
|
||||
parseAuthCommandArgs([
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--apikey",
|
||||
"key",
|
||||
"--modelid",
|
||||
"gpt-4.1",
|
||||
"--baseurl",
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
"--azure-api-version",
|
||||
"2025-01-01-preview",
|
||||
]),
|
||||
).toMatchObject({
|
||||
explicitProvider: "openai-compatible",
|
||||
apikey: "key",
|
||||
modelid: "gpt-4.1",
|
||||
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -67,6 +97,12 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+40
-124
@@ -3,11 +3,13 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -37,40 +39,6 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -81,6 +49,7 @@ type AuthQuickSetupInput = {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type AuthCommandInput = {
|
||||
@@ -90,6 +59,7 @@ type AuthCommandInput = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type ParsedAuthCommandArgs = {
|
||||
@@ -97,30 +67,10 @@ type ParsedAuthCommandArgs = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -137,7 +87,8 @@ export function createAuthCommand(): Command {
|
||||
.option("-p, --provider <id>", "provider id")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "model id")
|
||||
.option("-b, --baseurl <url>", "base URL");
|
||||
.option("-b, --baseurl <url>", "base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -154,6 +105,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}>();
|
||||
const positionalProvider = cmd.args[0];
|
||||
return {
|
||||
@@ -161,6 +113,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +153,12 @@ async function ensureQuickSetupInputValid(
|
||||
) {
|
||||
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
|
||||
}
|
||||
if (
|
||||
input.azureApiVersion?.trim() &&
|
||||
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
|
||||
) {
|
||||
return "Azure API version is only supported for OpenAI-compatible providers";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -209,6 +168,7 @@ function saveQuickAuthProviderSettings(input: {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}): void {
|
||||
const existing = input.providerSettingsManager.getProviderSettings(
|
||||
input.providerId,
|
||||
@@ -224,6 +184,12 @@ function saveQuickAuthProviderSettings(input: {
|
||||
if (input.baseurl?.trim()) {
|
||||
nextSettings.baseUrl = input.baseurl.trim();
|
||||
}
|
||||
if (input.azureApiVersion?.trim()) {
|
||||
nextSettings.azure = {
|
||||
...(nextSettings.azure ?? {}),
|
||||
apiVersion: input.azureApiVersion.trim(),
|
||||
};
|
||||
}
|
||||
input.providerSettingsManager.saveProviderSettings(nextSettings);
|
||||
}
|
||||
|
||||
@@ -272,64 +238,18 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -348,19 +268,14 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -370,12 +285,14 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
const apikey = input.apikey?.trim() ?? "";
|
||||
const modelid = input.modelid?.trim() ?? "";
|
||||
const baseurl = input.baseurl?.trim();
|
||||
const azureApiVersion = input.azureApiVersion?.trim();
|
||||
const validationError = await ensureQuickSetupInputValid(
|
||||
{
|
||||
provider: providerId,
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
},
|
||||
input.providerSettingsManager,
|
||||
);
|
||||
@@ -389,6 +306,7 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
});
|
||||
input.io.writeln(
|
||||
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
|
||||
@@ -473,12 +391,13 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
|
||||
const hasQuickSetupFlags =
|
||||
typeof input.apikey === "string" ||
|
||||
typeof input.modelid === "string" ||
|
||||
typeof input.baseurl === "string";
|
||||
typeof input.baseurl === "string" ||
|
||||
typeof input.azureApiVersion === "string";
|
||||
|
||||
if (hasQuickSetupFlags) {
|
||||
if (!input.explicitProvider?.trim()) {
|
||||
input.io.writeErr(
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -515,13 +434,10 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
|
||||
@@ -14,16 +14,30 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockCreateHubServerUrl,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
mockEnsureFileExists,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockStopAllConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
@@ -37,8 +51,22 @@ const {
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockCreateHubServerUrl: vi.fn(
|
||||
(host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(() => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
pathname: "/hub",
|
||||
})),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -52,10 +80,14 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
createHubServerUrl: mockCreateHubServerUrl,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
}));
|
||||
@@ -64,6 +96,10 @@ vi.mock("../connectors/common", () => ({
|
||||
isProcessRunning: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
@@ -76,7 +112,20 @@ describe("runDoctorCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
|
||||
mockResolveProductionHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
});
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -110,7 +159,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/apps/cli/src/index.ts"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/apps/cli/src/index.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
@@ -252,6 +302,115 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix queues active connectors for restart when killing hubs", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 1,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25466/hub" },
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
connectorProcesses: 2,
|
||||
connectorProcessesQueuedForRestart: 1,
|
||||
connectorRestartsQueued: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale random-port hub daemons", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "70001\n",
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"70001 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 25466 --pathname /hub",
|
||||
"70002 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 0 --pathname /hub",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(70002, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(70001, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
staleHubPids: [70002],
|
||||
},
|
||||
killed: {
|
||||
staleHubDaemons: 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
@@ -261,7 +420,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/src-tauri/bin/code-sidecar"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
|
||||
@@ -7,18 +7,21 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
@@ -54,6 +57,7 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -77,7 +81,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
|
||||
// "--" stops pgrep's option parsing so patterns that start with dashes
|
||||
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
|
||||
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
return [];
|
||||
}
|
||||
@@ -148,6 +156,25 @@ function listStaleCliPids(): number[] {
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleHubPids(currentHubPids: number[]): number[] {
|
||||
const current = new Set(currentHubPids.filter((pid) => pid > 0));
|
||||
const patterns = [
|
||||
"/sdk/packages/core/src/hub/daemon/entry.ts",
|
||||
"/sdk/packages/core/dist/hub/daemon/entry.js",
|
||||
"--cline-hub-daemon",
|
||||
];
|
||||
const records = new Map<number, ProcessRecord>();
|
||||
for (const pattern of patterns) {
|
||||
for (const record of listMatchingProcesses(pattern)) {
|
||||
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
|
||||
continue;
|
||||
}
|
||||
records.set(record.pid, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
@@ -235,7 +262,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
@@ -259,7 +286,7 @@ async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
@@ -291,14 +318,25 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
const listeningPids = listListeningPids(current?.port);
|
||||
const currentHubPids = [
|
||||
...(current?.pid ? [current.pid] : []),
|
||||
...listeningPids,
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
@@ -306,7 +344,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
hubPid: current?.pid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
listeningPids,
|
||||
staleHubPids: listStaleHubPids(currentHubPids),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
@@ -388,6 +427,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
@@ -412,6 +452,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -423,7 +464,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully().catch(() => false)
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
@@ -431,13 +474,25 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
const restartAwareStoppedConnectors = await stopConnectorsForHubs(
|
||||
before.activeConnectors.map((record) => record.hubUrl),
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
{ targetHubUrl: resolveDefaultCliHubUrl() },
|
||||
);
|
||||
const staleHubTargets = before.staleHubPids.filter(
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedStaleHubs = killPids(staleHubTargets);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid) &&
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
@@ -459,9 +514,15 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
connectorProcesses:
|
||||
stoppedConnectors.stoppedProcesses +
|
||||
restartAwareStoppedConnectors.stoppedProcesses,
|
||||
connectorProcessesQueuedForRestart:
|
||||
restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorRestartsQueued: restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorSessions: stoppedConnectors.stoppedSessions,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
hubDiscovery: clearedArtifacts.discovery,
|
||||
@@ -471,11 +532,20 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses + restartAwareStoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`queued connector restarts ${c.dim}${restartAwareStoppedConnectors.queuedRestarts}${c.reset}`,
|
||||
);
|
||||
if (restartAwareStoppedConnectors.queuedRestarts > 0) {
|
||||
writeln(
|
||||
`${c.dim}queued connectors relaunch automatically the next time the hub starts (any cline command, or 'cline hub start')${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln(
|
||||
`stopped connector sessions ${c.dim}${stoppedConnectors.stoppedSessions}${c.reset}`,
|
||||
);
|
||||
@@ -487,6 +557,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockRestartQueuedConnectorsForHub,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
})),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
mockStopLocalHubServerGracefully: vi.fn(),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
@@ -24,13 +39,34 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
resolveDefaultCliHubUrl: () => "ws://127.0.0.1:25463/hub",
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
|
||||
describe("createHubCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("includes uptime in hub status output", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(
|
||||
new Date("2026-01-01T00:01:05.000Z").getTime(),
|
||||
@@ -73,4 +109,108 @@ describe("createHubCommand", () => {
|
||||
uptime: "1m 5s",
|
||||
});
|
||||
});
|
||||
|
||||
it("queues associated connectors on stop", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 2,
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
["ws://127.0.0.1:25463/hub"],
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25463/hub" },
|
||||
);
|
||||
expect(JSON.parse(output.at(-1) || "")).toMatchObject({
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: 2,
|
||||
queuedConnectorRestarts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts queued connectors on start", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["start"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(output.at(-1)).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,23 +3,45 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "../connectors/restart";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
async function stopHubServer(
|
||||
_workspaceRoot: string,
|
||||
io: HubCommandIo,
|
||||
): Promise<{
|
||||
stopped: boolean;
|
||||
stoppedConnectorProcesses: number;
|
||||
queuedConnectorRestarts: number;
|
||||
}> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
const stoppedConnectors = discovery?.url
|
||||
? await stopConnectorsForHubs([discovery.url], io, {
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
})
|
||||
: { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
return {
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
}
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
@@ -30,7 +52,11 @@ async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return !!pid;
|
||||
return {
|
||||
stopped: !!pid,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
}
|
||||
|
||||
function formatHubUptimeFromStartedAt(
|
||||
@@ -46,6 +72,12 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -89,6 +121,7 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -106,16 +139,19 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
})
|
||||
: undefined;
|
||||
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
io.writeln(
|
||||
@@ -133,8 +169,7 @@ export function createHubCommand(
|
||||
hub.command("stop").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{ cwd: string }>();
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
io.writeln(JSON.stringify(await stopHubServer(opts.cwd, io)));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -78,6 +78,74 @@ describe("saveLocalProviderSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("merges and clears Azure provider settings", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
read: vi.fn().mockReturnValue({
|
||||
providers: {},
|
||||
}),
|
||||
write: vi.fn(),
|
||||
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
|
||||
getProviderSettings: vi.fn().mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2024-10-21",
|
||||
useIdentity: true,
|
||||
},
|
||||
}),
|
||||
saveProviderSettings: save,
|
||||
};
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: true,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
|
||||
save.mockClear();
|
||||
manager.getProviderSettings.mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth auth fields when updating manual apiKey", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
@@ -32,6 +36,21 @@ function createTempFile(pathSuffix: string): string {
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -96,6 +115,21 @@ describe("getInstallationInfo", () => {
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -153,6 +187,39 @@ describe("auto update settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,11 +5,17 @@ import {
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
resolveDefaultCliHubUrl,
|
||||
} from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -269,13 +275,22 @@ export function getPreferredKanbanInstaller(
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url).catch(() => undefined);
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
@@ -288,20 +303,32 @@ async function waitForHubToStop(
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url).catch(() => undefined)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!health?.url) return;
|
||||
if (!discovery || !health?.url) return;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
await stopConnectorsForHubs(
|
||||
[health.url],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
},
|
||||
);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -310,21 +337,22 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, 3_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, 2_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
// Re-ensure a fresh hub instance is spawned. ensureCliHubServer also
|
||||
// drains the connector restart queue for the new hub.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
await ensureCliHubServer(process.cwd());
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CLINE_CONNECTOR_RESTART_SPEC_ENV,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -113,6 +115,19 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
|
||||
protected writeStateFile(statePath: string, state: unknown): void {
|
||||
const restart = this.readRestartSpecFromEnv();
|
||||
if (
|
||||
restart &&
|
||||
state &&
|
||||
typeof state === "object" &&
|
||||
!Array.isArray(state)
|
||||
) {
|
||||
writeJsonFile(statePath, {
|
||||
...(state as Record<string, unknown>),
|
||||
restart,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJsonFile(statePath, state);
|
||||
}
|
||||
|
||||
@@ -120,6 +135,26 @@ export abstract class ConnectorBase<Options, State>
|
||||
removeFile(statePath);
|
||||
}
|
||||
|
||||
private readRestartSpecFromEnv(): ConnectorRestartSpec | undefined {
|
||||
const raw = process.env[CLINE_CONNECTOR_RESTART_SPEC_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ConnectorRestartSpec>;
|
||||
if (
|
||||
parsed.connector === this.name &&
|
||||
Array.isArray(parsed.args) &&
|
||||
parsed.args.every((arg) => typeof arg === "string")
|
||||
) {
|
||||
return { connector: parsed.connector, args: parsed.args };
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed restart metadata from the environment.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected removeStaleState(
|
||||
statePath: string,
|
||||
readState: (path: string) => State | undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
@@ -15,6 +16,8 @@ import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_RESTART_SPEC_ENV = "CLINE_CONNECTOR_RESTART_SPEC";
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -183,6 +186,8 @@ export function spawnDetachedConnector(
|
||||
}
|
||||
const detachedLogFd = tryOpenDetachedLogFd(options?.logPath);
|
||||
try {
|
||||
const connectorName =
|
||||
commandPrefixArgs[0] === "connect" ? commandPrefixArgs[1] : undefined;
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
@@ -193,6 +198,14 @@ export function spawnDetachedConnector(
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
...(connectorName
|
||||
? {
|
||||
[CLINE_CONNECTOR_RESTART_SPEC_ENV]: JSON.stringify({
|
||||
connector: connectorName,
|
||||
args: rawArgs,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
logSpawnedProcess({
|
||||
@@ -259,7 +272,20 @@ export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureParentDir(path);
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
// Connector state and the restart queue persist raw CLI args, which can
|
||||
// include secrets like bot tokens. Recreate the file owner-only, matching
|
||||
// the discipline used for hub discovery records. The mode option only
|
||||
// applies on create, so remove any existing file first.
|
||||
rmSync(path, { force: true });
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best-effort tightening on filesystems without chmod support.
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveClineDataDir, mockGetConnector } = vi.hoisted(() => ({
|
||||
mockResolveClineDataDir: vi.fn(),
|
||||
mockGetConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
ensureParentDir: (path: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./registry", () => ({
|
||||
getConnector: mockGetConnector,
|
||||
}));
|
||||
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "./restart";
|
||||
|
||||
describe("connector restart queue", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockGetConnector.mockReset();
|
||||
mockResolveClineDataDir.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("queues connector restart metadata when stopping connectors for a killed hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address", "ws://127.0.0.1:57648/hub"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
const killSpy = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(
|
||||
["ws://127.0.0.1:57648/hub"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
},
|
||||
);
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(killSpy).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(existsSync(statePath)).toBe(false);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
pid: 12345,
|
||||
},
|
||||
]);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rewrites equals-form rpc address args when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address=ws://127.0.0.1:57648/hub"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address=ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("only restarts queue entries targeted at the started hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-a"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-a.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-b"],
|
||||
hubUrl: "ws://127.0.0.1:57649/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-b.json"),
|
||||
pid: 12346,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 1 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot-a", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
args: ["-m", "bot-b"],
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes the restart queue owner-only",
|
||||
async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["--bot-token", "secret"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(statSync(queuePath).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims queue entries before launching connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let queueExistedDuringRun: boolean | undefined;
|
||||
const run = vi.fn(async () => {
|
||||
queueExistedDuringRun = existsSync(queuePath);
|
||||
return 0;
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(queueExistedDuringRun).toBe(false);
|
||||
});
|
||||
|
||||
it("drops queue entries for unknown connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "renamed-connector",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockResolvedValue(undefined);
|
||||
const errors: string[] = [];
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for unknown connector "renamed-connector"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues failed restarts with an attempt count and drops them at the cap", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
const entry = {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(queuePath, JSON.stringify([entry]), "utf8");
|
||||
const run = vi.fn(async () => 1);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
|
||||
const first = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(first).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
|
||||
const second = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(second).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 2 },
|
||||
]);
|
||||
|
||||
const errors: string[] = [];
|
||||
const third = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(third).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for connector "telegram" after 3 failed attempts',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when the connector run throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error("spawn failed");
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps state and skips restart queue when connector termination fails", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (Number(pid) === 12345) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const errors: string[] = [];
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 0, queuedRestarts: 0 });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
"[connect] failed to stop connector pid=12345 hub=ws://127.0.0.1:57648/hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-directory entries while scanning connector state", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(queuePath, "[]", "utf8");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
targetHubUrl: "ws://127.0.0.1:57648/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
terminateProcess,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import { getConnector } from "./registry";
|
||||
import type { ConnectIo, ConnectorRestartSpec } from "./types";
|
||||
|
||||
type ConnectorStateForRestart = {
|
||||
statePath: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
restart?: ConnectorRestartSpec;
|
||||
};
|
||||
|
||||
type QueuedConnectorRestart = ConnectorRestartSpec & {
|
||||
hubUrl: string;
|
||||
targetHubUrl: string;
|
||||
statePath: string;
|
||||
pid: number;
|
||||
stoppedAt: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
export type StopConnectorsForHubsOptions = {
|
||||
targetHubUrl?: string;
|
||||
};
|
||||
|
||||
export type StopConnectorsForHubsResult = {
|
||||
stoppedProcesses: number;
|
||||
queuedRestarts: number;
|
||||
};
|
||||
|
||||
export type RestartQueuedConnectorsResult = {
|
||||
restarted: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
function restartQueuePath(): string {
|
||||
return join(resolveClineDataDir(), "connectors", "restart-queue.json");
|
||||
}
|
||||
|
||||
function normalizeHubUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `ws://${url}`);
|
||||
if (parsed.protocol === "http:") {
|
||||
parsed.protocol = "ws:";
|
||||
} else if (parsed.protocol === "https:") {
|
||||
parsed.protocol = "wss:";
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function readQueue(): QueuedConnectorRestart[] {
|
||||
const parsed = readJsonFile<unknown>(restartQueuePath(), []);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((entry): entry is QueuedConnectorRestart => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = entry as Partial<QueuedConnectorRestart>;
|
||||
return (
|
||||
typeof record.connector === "string" &&
|
||||
Array.isArray(record.args) &&
|
||||
record.args.every((arg) => typeof arg === "string") &&
|
||||
typeof record.hubUrl === "string" &&
|
||||
typeof record.targetHubUrl === "string" &&
|
||||
typeof record.statePath === "string" &&
|
||||
typeof record.pid === "number" &&
|
||||
typeof record.stoppedAt === "string" &&
|
||||
(record.attempts === undefined || typeof record.attempts === "number")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function writeQueue(queue: QueuedConnectorRestart[]): void {
|
||||
if (queue.length === 0) {
|
||||
removeFile(restartQueuePath());
|
||||
return;
|
||||
}
|
||||
writeJsonFile(restartQueuePath(), queue);
|
||||
}
|
||||
|
||||
function listConnectorStatePaths(): string[] {
|
||||
const root = join(resolveClineDataDir(), "connectors");
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dir = join(root, entry.name);
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.endsWith(".json") && !name.endsWith(".threads.json")) {
|
||||
paths.push(join(dir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore connector directories that disappear while scanning.
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function readConnectorStateForRestart(
|
||||
statePath: string,
|
||||
): ConnectorStateForRestart | undefined {
|
||||
const parsed = readJsonFile<Record<string, unknown> | undefined>(
|
||||
statePath,
|
||||
undefined,
|
||||
);
|
||||
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 restart =
|
||||
parsed.restart &&
|
||||
typeof parsed.restart === "object" &&
|
||||
!Array.isArray(parsed.restart)
|
||||
? (parsed.restart as Partial<ConnectorRestartSpec>)
|
||||
: undefined;
|
||||
return {
|
||||
statePath,
|
||||
pid,
|
||||
hubUrl,
|
||||
restart:
|
||||
typeof restart?.connector === "string" &&
|
||||
Array.isArray(restart.args) &&
|
||||
restart.args.every((arg) => typeof arg === "string")
|
||||
? { connector: restart.connector, args: restart.args }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function queueConnectorRestart(
|
||||
state: ConnectorStateForRestart,
|
||||
targetHubUrl: string,
|
||||
): boolean {
|
||||
if (!state.restart) {
|
||||
return false;
|
||||
}
|
||||
const queue = readQueue().filter(
|
||||
(entry) =>
|
||||
entry.statePath !== state.statePath &&
|
||||
!(
|
||||
entry.connector === state.restart?.connector && entry.pid === state.pid
|
||||
),
|
||||
);
|
||||
queue.push({
|
||||
...state.restart,
|
||||
hubUrl: state.hubUrl,
|
||||
targetHubUrl,
|
||||
statePath: state.statePath,
|
||||
pid: state.pid,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
});
|
||||
writeQueue(queue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopConnectorsForHubs(
|
||||
hubUrls: string[],
|
||||
io: ConnectIo,
|
||||
options: StopConnectorsForHubsOptions = {},
|
||||
): Promise<StopConnectorsForHubsResult> {
|
||||
const targetHubUrls = new Set(hubUrls.map(normalizeHubUrl));
|
||||
if (targetHubUrls.size === 0) {
|
||||
return { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
}
|
||||
const restartTargetHubUrl = options.targetHubUrl
|
||||
? normalizeHubUrl(options.targetHubUrl)
|
||||
: undefined;
|
||||
let stoppedProcesses = 0;
|
||||
let queuedRestarts = 0;
|
||||
for (const statePath of listConnectorStatePaths()) {
|
||||
const state = readConnectorStateForRestart(statePath);
|
||||
if (!state || !targetHubUrls.has(normalizeHubUrl(state.hubUrl))) {
|
||||
continue;
|
||||
}
|
||||
if (!(await terminateProcess(state.pid))) {
|
||||
io.writeErr(
|
||||
`[connect] failed to stop connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
stoppedProcesses += 1;
|
||||
io.writeln(
|
||||
`[connect] stopped connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
if (
|
||||
queueConnectorRestart(
|
||||
state,
|
||||
restartTargetHubUrl ?? normalizeHubUrl(state.hubUrl),
|
||||
)
|
||||
) {
|
||||
queuedRestarts += 1;
|
||||
}
|
||||
removeFile(statePath);
|
||||
}
|
||||
return { stoppedProcesses, queuedRestarts };
|
||||
}
|
||||
|
||||
function withHubRpcAddress(args: string[], hubUrl: string): string[] {
|
||||
const next = args.filter((arg) => arg !== "-i" && arg !== "--interactive");
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
if (next[index]?.startsWith("--rpc-address=")) {
|
||||
next[index] = `--rpc-address=${hubUrl}`;
|
||||
return next;
|
||||
}
|
||||
if (next[index] === "--rpc-address" && next[index + 1]) {
|
||||
next[index + 1] = hubUrl;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [...next, "--rpc-address", hubUrl];
|
||||
}
|
||||
|
||||
// Restarting a connector re-runs its connect command, which ensures the hub
|
||||
// and drains this queue again. The guard turns those nested drains into
|
||||
// no-ops so a queue entry is never picked up twice within one process.
|
||||
let drainInProgress = false;
|
||||
|
||||
export async function restartQueuedConnectorsForHub(
|
||||
hubUrl: string,
|
||||
io: ConnectIo,
|
||||
): Promise<RestartQueuedConnectorsResult> {
|
||||
if (drainInProgress) {
|
||||
return { restarted: 0, remaining: readQueue().length };
|
||||
}
|
||||
const queue = readQueue();
|
||||
if (queue.length === 0) {
|
||||
return { restarted: 0, remaining: 0 };
|
||||
}
|
||||
const targetHubUrl = normalizeHubUrl(hubUrl);
|
||||
const matched: QueuedConnectorRestart[] = [];
|
||||
const remaining: QueuedConnectorRestart[] = [];
|
||||
for (const entry of queue) {
|
||||
if (normalizeHubUrl(entry.targetHubUrl) === targetHubUrl) {
|
||||
matched.push(entry);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
return { restarted: 0, remaining: remaining.length };
|
||||
}
|
||||
// Claim matched entries before running them so a crash mid-restart (or a
|
||||
// concurrent drain in another process) cannot replay entries that already
|
||||
// launched a connector.
|
||||
writeQueue(remaining);
|
||||
let restarted = 0;
|
||||
const failed: QueuedConnectorRestart[] = [];
|
||||
drainInProgress = true;
|
||||
try {
|
||||
for (const entry of matched) {
|
||||
const connector = await getConnector(entry.connector);
|
||||
if (!connector) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for unknown connector "${entry.connector}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const exitCode = await connector
|
||||
.run(withHubRpcAddress(entry.args, hubUrl), io)
|
||||
.catch(() => 1);
|
||||
if (exitCode === 0) {
|
||||
restarted += 1;
|
||||
continue;
|
||||
}
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
if (attempts >= MAX_RESTART_ATTEMPTS) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for connector "${entry.connector}" after ${attempts} failed attempts`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
failed.push({ ...entry, attempts });
|
||||
}
|
||||
} finally {
|
||||
drainInProgress = false;
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Re-read before appending so entries queued while restarting survive.
|
||||
writeQueue([...readQueue(), ...failed]);
|
||||
}
|
||||
return { restarted, remaining: readQueue().length };
|
||||
}
|
||||
@@ -8,6 +8,11 @@ export type ConnectStopResult = {
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectorRestartSpec = {
|
||||
connector: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
@@ -152,6 +152,7 @@ export async function runCli(): Promise<void> {
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "Model ID")
|
||||
.option("-b, --baseurl <url>", "Base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
@@ -165,6 +166,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -195,6 +197,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
@@ -9,13 +9,14 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
@@ -32,7 +33,6 @@ vi.mock("@cline/core", () => {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
getValidClineCredentials: coreMocks.getValidClineCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -59,15 +59,51 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.getValidClineCredentials.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -77,26 +113,12 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
|
||||
{
|
||||
access: "old-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 1,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
{ apiBaseUrl: "https://api.cline.bot" },
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
@@ -115,6 +137,14 @@ describe("createClineAccountService", () => {
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -123,7 +153,6 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue(null);
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
|
||||
@@ -4,16 +4,18 @@ import {
|
||||
type ClineAccountOrganizationBalance,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
@@ -69,26 +71,13 @@ function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const persistedAccessToken =
|
||||
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
const settingsApiKey =
|
||||
input.clineProviderSettings?.apiKey?.trim() ||
|
||||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
|
||||
"";
|
||||
|
||||
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
|
||||
if (authToken.toLowerCase().startsWith("workos:workos:")) {
|
||||
authToken = authToken.slice("workos:".length);
|
||||
}
|
||||
return authToken || undefined;
|
||||
}
|
||||
|
||||
function stripWorkosTokenPrefix(accessToken: string): string {
|
||||
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: accessToken;
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
@@ -98,43 +87,26 @@ async function resolveValidClineAccountAuthToken(input: {
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const auth = settings?.auth;
|
||||
const accessToken = auth?.accessToken?.trim();
|
||||
const refreshToken = auth?.refreshToken?.trim();
|
||||
if (settings && auth && accessToken && refreshToken) {
|
||||
const credentials = await getValidClineCredentials(
|
||||
{
|
||||
access: stripWorkosTokenPrefix(accessToken),
|
||||
refresh: refreshToken,
|
||||
expires: auth.expiresAt ?? Date.now() - 1,
|
||||
accountId: auth.accountId,
|
||||
},
|
||||
{ apiBaseUrl: input.apiBaseUrl },
|
||||
);
|
||||
if (!credentials) {
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = toProviderApiKey("cline", credentials);
|
||||
if (
|
||||
nextAccessToken !== accessToken ||
|
||||
credentials.refresh !== refreshToken ||
|
||||
credentials.accountId !== auth.accountId ||
|
||||
credentials.expires !== auth.expiresAt
|
||||
) {
|
||||
input.manager.saveProviderSettings(
|
||||
{
|
||||
...settings,
|
||||
auth: {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: nextAccessToken,
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
@@ -21,12 +22,13 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -315,8 +317,11 @@ export function UseExistingOrReconfigureContent(
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
gcpProjectId: "Google Cloud Project ID",
|
||||
gcpRegion: "Google Cloud Region",
|
||||
sapClientId: "Client ID",
|
||||
sapClientSecret: "Client Secret",
|
||||
sapTokenUrl: "Token URL",
|
||||
@@ -329,8 +334,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "sk-...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
gcpProjectId: "my-gcp-project",
|
||||
gcpRegion: "us-central1",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
sapClientSecret: "SAP AI Core client secret",
|
||||
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
|
||||
@@ -341,7 +349,10 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
/** Render order for cycling focus with Tab. */
|
||||
const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
@@ -398,11 +409,22 @@ export function ProviderConfigInputContent(
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initial.azureApiVersion =
|
||||
existingSettings?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const ep = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
initial.awsRegion =
|
||||
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
|
||||
}
|
||||
if (config.fields.gcpProjectId)
|
||||
initial.gcpProjectId = existingSettings?.gcp?.projectId?.trim() ?? "";
|
||||
if (config.fields.gcpRegion)
|
||||
initial.gcpRegion =
|
||||
existingSettings?.gcp?.region?.trim() ??
|
||||
config.fields.gcpRegion.defaultValue ??
|
||||
"us-central1";
|
||||
if (config.fields.apiKey)
|
||||
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
|
||||
if (config.fields.awsProfile)
|
||||
@@ -430,7 +452,9 @@ export function ProviderConfigInputContent(
|
||||
const submit = () => {
|
||||
const apiKey = values.apiKey?.trim();
|
||||
const awsProfile = values.awsProfile?.trim();
|
||||
const hasAzureFields = config.fields.azureApiVersion;
|
||||
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
|
||||
const hasGcpFields = config.fields.gcpProjectId || config.fields.gcpRegion;
|
||||
const hasSapFields =
|
||||
config.fields.sapClientId ||
|
||||
config.fields.sapClientSecret ||
|
||||
@@ -441,6 +465,7 @@ export function ProviderConfigInputContent(
|
||||
providerId,
|
||||
apiKey: config.fields.apiKey ? apiKey : undefined,
|
||||
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(values),
|
||||
@@ -448,6 +473,7 @@ export function ProviderConfigInputContent(
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
gcp: hasGcpFields ? resolveProviderConfigGcp(values) : undefined,
|
||||
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
|
||||
});
|
||||
resolve(true);
|
||||
@@ -671,7 +697,7 @@ export function OAuthLoginContent(
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
@@ -705,30 +731,24 @@ export function OAuthLoginContent(
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
|
||||
loginLocalProvider(
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
existing,
|
||||
(url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
loginLocalProvider(providerId, existing, (url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
}
|
||||
})
|
||||
.then((credentials) => {
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "./provider-config-values";
|
||||
@@ -66,6 +68,18 @@ describe("provider config values", () => {
|
||||
).toBe("us-west-2");
|
||||
});
|
||||
|
||||
it("resolves Vertex GCP field values into GCP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigGcp({ gcpRegion: "us-central1" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveProviderConfigGcp({
|
||||
gcpProjectId: " project ",
|
||||
gcpRegion: " europe-west4 ",
|
||||
}),
|
||||
).toEqual({ projectId: "project", region: "europe-west4" });
|
||||
});
|
||||
|
||||
it("resolves SAP AI Core field values into SAP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigSap({
|
||||
@@ -83,4 +97,24 @@ describe("provider config values", () => {
|
||||
deploymentId: "deployment",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Azure API version into Azure settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " 2025-01-01-preview ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps blank Azure API version so persisted settings can be cleared", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ProviderConfigValues = Partial<
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
@@ -20,6 +21,20 @@ export function resolveProviderConfigAwsRegion(
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
@@ -41,6 +56,12 @@ export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
type ITelemetryService,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
@@ -9,16 +10,12 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
|
||||
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
export function isOnboardingOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OnboardingOAuthProviderId {
|
||||
return (
|
||||
providerId === "cline" ||
|
||||
providerId === "oca" ||
|
||||
providerId === "openai-codex"
|
||||
);
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -382,6 +383,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initialValues.azureApiVersion =
|
||||
existing?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const existingProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
initialValues.awsRegion =
|
||||
@@ -444,6 +449,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
// surfaced when the model picker / first turn runs.
|
||||
const apiKey = byoValues.apiKey?.trim();
|
||||
const awsProfile = byoValues.awsProfile?.trim();
|
||||
const hasAzureFields = byoFields.azureApiVersion;
|
||||
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
|
||||
const hasSapFields =
|
||||
byoFields.sapClientId ||
|
||||
@@ -456,6 +462,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId: activeProviderId,
|
||||
apiKey: byoFields.apiKey ? apiKey : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(byoValues),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
|
||||
@@ -222,6 +222,7 @@ import type {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
sapClientId: "Client ID",
|
||||
@@ -236,6 +237,7 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "Paste your API key here...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureDetachedHubServer, mockRestartQueuedConnectorsForHub } =
|
||||
vi.hoisted(() => ({
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
createHubServerUrl: (host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
resolveDefaultHubHost: () => "127.0.0.1",
|
||||
resolveDefaultHubPort: () => 25463,
|
||||
resolveHubEndpointOptions: () => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
pathname: "/hub",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
}));
|
||||
|
||||
import { ensureCliHubServer } from "./hub-runtime";
|
||||
|
||||
describe("ensureCliHubServer", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("drains the connector restart queue after ensuring the hub", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the hub resolution even when draining the queue fails", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRestartQueuedConnectorsForHub.mockRejectedValueOnce(
|
||||
new Error("queue unreadable"),
|
||||
);
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
createHubServerUrl,
|
||||
type DetachedHubResolution,
|
||||
ensureDetachedHubServer,
|
||||
type HubEndpointOverrides,
|
||||
resolveDefaultHubHost,
|
||||
resolveDefaultHubPort,
|
||||
resolveHubEndpointOptions,
|
||||
} from "@cline/core";
|
||||
import { restartQueuedConnectorsForHub } from "../connectors/restart";
|
||||
|
||||
/**
|
||||
* Build a `host:port` rpc address string that respects the current build
|
||||
@@ -15,6 +18,11 @@ export function resolveDefaultCliRpcAddress(): string {
|
||||
return `${resolveDefaultHubHost()}:${resolveDefaultHubPort()}`;
|
||||
}
|
||||
|
||||
export function resolveDefaultCliHubUrl(): string {
|
||||
const endpoint = resolveHubEndpointOptions();
|
||||
return createHubServerUrl(endpoint.host, endpoint.port, endpoint.pathname);
|
||||
}
|
||||
|
||||
export function parseHubEndpointOverride(
|
||||
rawAddress: string | undefined,
|
||||
): HubEndpointOverrides {
|
||||
@@ -43,5 +51,12 @@ export async function ensureCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
return await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
const resolution = await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
// Connectors queued by hub stop/doctor/update cleanup come back as soon
|
||||
// as any CLI path brings the hub up, not only explicit hub commands.
|
||||
await restartQueuedConnectorsForHub(resolution.url, {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
}).catch(() => undefined);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Llms, type ProviderSettings } from "@cline/core";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
|
||||
export type OAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type OAuthCredentials = ProviderOAuthCredentials;
|
||||
|
||||
export function normalizeProviderId(providerId: string): string {
|
||||
return Llms.normalizeProviderId(providerId.trim());
|
||||
@@ -22,42 +21,20 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exports `isOAuthProviderId` from `@cline/shared` so the CLI has a
|
||||
* single source of truth for the OAuth provider list. Existing call sites
|
||||
* keep their `isOAuthProvider` import name.
|
||||
*/
|
||||
export const isOAuthProvider = isOAuthProviderId;
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<OAuthCredentials, "access">,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
return formatProviderOAuthApiKey(providerId, credentials);
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return toProviderApiKey(providerId, { access: accessToken });
|
||||
}
|
||||
const shorthandKey = settings?.apiKey?.trim();
|
||||
if (shorthandKey) {
|
||||
return shorthandKey;
|
||||
}
|
||||
const authKey = settings?.auth?.apiKey?.trim();
|
||||
if (authKey) {
|
||||
return authKey;
|
||||
}
|
||||
return undefined;
|
||||
return getCorePersistedProviderApiKey(providerId, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +53,7 @@ export function isProviderConfigured(
|
||||
settings: ProviderSettings | undefined,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProviderId(providerId)) {
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
|
||||
@@ -139,6 +139,12 @@ describe("provider readiness", () => {
|
||||
gcp: { projectId: "test-project" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("vertex", {
|
||||
provider: "vertex",
|
||||
gcp: { projectId: "test-project", region: "us-central1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
|
||||
@@ -33,6 +33,8 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -117,17 +116,10 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -134,17 +133,10 @@ export async function runProviderOAuthLogin(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setDisabledPlugin,
|
||||
@@ -1012,10 +1011,9 @@ export async function handleCommand(
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
@@ -1033,12 +1031,6 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Llms,
|
||||
ProviderSettingsManager,
|
||||
stopLocalHubServerGracefully,
|
||||
toHubHealthUrl,
|
||||
toHubStatusUrl,
|
||||
} from "@cline/core";
|
||||
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
|
||||
|
||||
@@ -460,7 +460,11 @@ async function main(): Promise<void> {
|
||||
|
||||
const syncHealthState = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(hubUrl));
|
||||
const response = await fetch(toHubStatusUrl(hubUrl), {
|
||||
headers: hubAuthToken
|
||||
? { authorization: `Bearer ${hubAuthToken}` }
|
||||
: undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -701,7 +701,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
|
||||
if (this.hubUrl) {
|
||||
const healthy = await probeHubServer(this.hubUrl);
|
||||
const healthy = await probeHubServer(this.hubUrl, {
|
||||
authToken: this.hubAuthToken,
|
||||
});
|
||||
if (healthy?.url) {
|
||||
return {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
|
||||
@@ -733,7 +735,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
): Promise<HubResolution | undefined> {
|
||||
const discovery = await readHubDiscovery(discoveryPath);
|
||||
if (!discovery?.url) return undefined;
|
||||
const healthy = await probeHubServer(discovery.url);
|
||||
const healthy = await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
});
|
||||
return healthy?.url
|
||||
? {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.88.1",
|
||||
"version": "3.89.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.88.1",
|
||||
"version": "3.89.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -18543,9 +18543,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
|
||||
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
|
||||
@@ -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.88.1",
|
||||
"version": "3.89.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -87,6 +87,30 @@ describe("AnthropicHandler", () => {
|
||||
result.id.should.equal("claude-opus-4-8:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-opus-4-8:1m"])
|
||||
})
|
||||
|
||||
it("should return the Fable 5 model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-fable-5",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-fable-5")
|
||||
result.info.should.deepEqual(anthropicModels["claude-fable-5"])
|
||||
})
|
||||
|
||||
it("should return the Fable 5 1m model when configured", () => {
|
||||
const handler = new AnthropicHandler({
|
||||
apiKey: "test-api-key",
|
||||
apiModelId: "claude-fable-5:1m",
|
||||
})
|
||||
|
||||
const result = handler.getModel()
|
||||
|
||||
result.id.should.equal("claude-fable-5:1m")
|
||||
result.info.should.deepEqual(anthropicModels["claude-fable-5:1m"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
|
||||
@@ -215,6 +215,11 @@ describe("AwsBedrockHandler", () => {
|
||||
bedrockModels["anthropic.claude-opus-4-8:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should mark Bedrock Fable 5 variants as global-endpoint capable", () => {
|
||||
bedrockModels["anthropic.claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
bedrockModels["anthropic.claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
})
|
||||
|
||||
it("should include Vertex Opus 4.7 variants in the derived global model list", () => {
|
||||
vertexModels["claude-opus-4-7"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-opus-4-7:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
@@ -228,6 +233,13 @@ describe("AwsBedrockHandler", () => {
|
||||
vertexGlobalModels.should.have.property("claude-opus-4-8")
|
||||
vertexGlobalModels.should.have.property("claude-opus-4-8:1m")
|
||||
})
|
||||
|
||||
it("should include Vertex Fable 5 variants in the derived global model list", () => {
|
||||
vertexModels["claude-fable-5"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexModels["claude-fable-5:1m"].supportsGlobalEndpoint.should.equal(true)
|
||||
vertexGlobalModels.should.have.property("claude-fable-5")
|
||||
vertexGlobalModels.should.have.property("claude-fable-5:1m")
|
||||
})
|
||||
})
|
||||
|
||||
const mockOptions: AwsBedrockHandlerOptions = {
|
||||
|
||||
@@ -416,6 +416,26 @@ describe("ClaudeCodeHandler", () => {
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Fable 5 model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-fable-5",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-fable-5")
|
||||
model.info.contextWindow.should.equal(200_000)
|
||||
})
|
||||
|
||||
it("should support Fable 5 1m model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "claude-fable-5[1m]",
|
||||
})
|
||||
|
||||
const model = handler.getModel()
|
||||
model.id.should.equal("claude-fable-5[1m]")
|
||||
model.info.contextWindow.should.equal(1_000_000)
|
||||
})
|
||||
|
||||
it("should support Opus 1m alias model id", () => {
|
||||
const handler = new ClaudeCodeHandler({
|
||||
apiModelId: "opus[1m]",
|
||||
|
||||
@@ -81,11 +81,12 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
|
||||
const isDeepSeekThinkingModel =
|
||||
model.id.includes("deepseek-reasoner") || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekThinkingModel
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
@@ -104,6 +105,13 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -115,13 +123,6 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
OPENROUTER_PROVIDER_PREFERENCES,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
@@ -63,7 +64,8 @@ export async function createOpenRouterStream(
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId ||
|
||||
model.id === openRouterClaudeOpus471mModelId ||
|
||||
model.id === openRouterClaudeOpus481mModelId
|
||||
model.id === openRouterClaudeOpus481mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id openrouter API expects
|
||||
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import {
|
||||
CLAUDE_SONNET_1M_SUFFIX,
|
||||
ModelInfo,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
@@ -39,7 +40,8 @@ export async function createVercelAIGatewayStream(
|
||||
model.id === openRouterClaudeSonnet461mModelId ||
|
||||
model.id === openRouterClaudeOpus461mModelId ||
|
||||
model.id === openRouterClaudeOpus471mModelId ||
|
||||
model.id === openRouterClaudeOpus481mModelId
|
||||
model.id === openRouterClaudeOpus481mModelId ||
|
||||
model.id === openRouterClaudeFable51mModelId
|
||||
if (isClaude1m) {
|
||||
// remove the custom :1m suffix, to create the model id the API expects
|
||||
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import * as disk from "@core/storage/disk"
|
||||
import { openRouterClaudeFable51mModelId } from "@shared/api"
|
||||
import axios from "axios"
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import sinon from "sinon"
|
||||
import { ClineEnv, Environment } from "@/config"
|
||||
import type { Controller } from "@/core/controller"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getFeatureFlagsService } from "@/services/feature-flags"
|
||||
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
|
||||
@@ -37,7 +39,7 @@ describe("refreshClineModels", () => {
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getModelsCache: () => null,
|
||||
setModelsCache: () => {},
|
||||
} as any)
|
||||
} as unknown as StateManager)
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
|
||||
sandbox.stub(fs, "writeFile").resolves()
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
@@ -67,11 +69,70 @@ describe("refreshClineModels", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const models = await refreshClineModels({} as any)
|
||||
const models = await refreshClineModels({} as Controller)
|
||||
const qwen37 = models["qwen/qwen3.7-max"]
|
||||
|
||||
expect(qwen37.supportsPromptCache).to.equal(true)
|
||||
expect(qwen37.cacheReadsPrice).to.equal(0.25)
|
||||
expect(qwen37.cacheWritesPrice).to.equal(undefined)
|
||||
})
|
||||
|
||||
it("adds Claude Fable 5 context variants to the Cline model list", async () => {
|
||||
sandbox.stub(getFeatureFlagsService(), "getBooleanFlagEnabled").callsFake((flag) => {
|
||||
return flag === FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT
|
||||
})
|
||||
sandbox.stub(ClineEnv, "config").returns({
|
||||
environment: Environment.production,
|
||||
appBaseUrl: "https://app.cline-mock.bot",
|
||||
apiBaseUrl: "https://api.cline-mock.bot",
|
||||
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
|
||||
})
|
||||
sandbox.stub(StateManager, "get").returns({
|
||||
getModelsCache: () => null,
|
||||
setModelsCache: () => {},
|
||||
} as unknown as StateManager)
|
||||
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
|
||||
sandbox.stub(fs, "writeFile").resolves()
|
||||
sandbox.stub(axios, "get").resolves({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "anthropic/claude-fable-5",
|
||||
name: "Claude Fable 5",
|
||||
description: "Fetched description",
|
||||
context_length: 1_000_000,
|
||||
top_provider: {
|
||||
max_completion_tokens: 128_000,
|
||||
context_length: 1_000_000,
|
||||
is_moderated: false,
|
||||
},
|
||||
architecture: {
|
||||
modality: ["text", "image"],
|
||||
},
|
||||
pricing: {
|
||||
prompt: "0.00001",
|
||||
completion: "0.00005",
|
||||
input_cache_read: "0.000001",
|
||||
input_cache_write: "0.0000125",
|
||||
},
|
||||
supported_parameters: ["include_reasoning", "reasoning"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const models = await refreshClineModels({} as Controller)
|
||||
const fable = models["anthropic/claude-fable-5"]
|
||||
const fable1m = models[openRouterClaudeFable51mModelId]
|
||||
|
||||
expect(fable.contextWindow).to.equal(200_000)
|
||||
expect(fable.maxTokens).to.equal(128_000)
|
||||
expect(fable.supportsPromptCache).to.equal(true)
|
||||
expect(fable.inputPrice).to.equal(10)
|
||||
expect(fable.outputPrice).to.equal(50)
|
||||
expect(fable.cacheWritesPrice).to.equal(12.5)
|
||||
expect(fable.cacheReadsPrice).to.equal(1)
|
||||
expect(fable1m.contextWindow).to.equal(1_000_000)
|
||||
expect(fable1m.tiers).to.not.equal(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,10 @@ import { StateManager } from "@/core/storage/StateManager"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import {
|
||||
ANTHROPIC_MAX_THINKING_BUDGET,
|
||||
CLAUDE_FABLE_1M_TIERS,
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
@@ -78,7 +80,7 @@ interface ClineRawModelInfo {
|
||||
input_cache_write?: string
|
||||
} | null
|
||||
supports_global_endpoint?: boolean | null
|
||||
tiers?: any[] | null
|
||||
tiers?: ModelInfo["tiers"] | null
|
||||
supported_parameters?: ClineSupportedParams[] | null
|
||||
}
|
||||
|
||||
@@ -138,7 +140,7 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
let models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const rawModels = await fetchRawClineModels()
|
||||
const parsePrice = (price: any) => {
|
||||
const parsePrice = (price: unknown) => {
|
||||
if (price === undefined || price === null || price === "") {
|
||||
return undefined
|
||||
}
|
||||
@@ -204,6 +206,14 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-fable-5":
|
||||
modelInfo.contextWindow = 200_000
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 10
|
||||
modelInfo.outputPrice = 50
|
||||
modelInfo.cacheWritesPrice = 12.5
|
||||
modelInfo.cacheReadsPrice = 1
|
||||
break
|
||||
case "anthropic/claude-opus-4.5":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
@@ -299,6 +309,12 @@ async function fetchAndCacheClineModels(): Promise<Record<string, ModelInfo>> {
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
claudeFable1mModelInfo.tiers = CLAUDE_FABLE_1M_TIERS
|
||||
models[openRouterClaudeFable51mModelId] = claudeFable1mModelInfo
|
||||
}
|
||||
}
|
||||
if (Object.keys(models).length === 0) {
|
||||
throw new Error("No Cline models returned from API")
|
||||
|
||||
@@ -8,8 +8,10 @@ import path from "path"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import {
|
||||
ANTHROPIC_MAX_THINKING_BUDGET,
|
||||
CLAUDE_FABLE_1M_TIERS,
|
||||
CLAUDE_OPUS_1M_TIERS,
|
||||
CLAUDE_SONNET_1M_TIERS,
|
||||
openRouterClaudeFable51mModelId,
|
||||
openRouterClaudeOpus461mModelId,
|
||||
openRouterClaudeOpus471mModelId,
|
||||
openRouterClaudeOpus481mModelId,
|
||||
@@ -179,6 +181,14 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
modelInfo.cacheReadsPrice = 0.5
|
||||
break
|
||||
case "anthropic/claude-fable-5":
|
||||
modelInfo.contextWindow = 200_000 // restrict to 200k, 1m variant created below
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.inputPrice = 10
|
||||
modelInfo.outputPrice = 50
|
||||
modelInfo.cacheWritesPrice = 12.5
|
||||
modelInfo.cacheReadsPrice = 1
|
||||
break
|
||||
case "anthropic/claude-opus-4.5":
|
||||
modelInfo.supportsPromptCache = true
|
||||
modelInfo.cacheWritesPrice = 6.25
|
||||
@@ -322,6 +332,12 @@ async function fetchAndCacheModels(controller: Controller): Promise<Record<strin
|
||||
models[openRouterClaudeOpus481mModelId] = claudeOpus1mModelInfo
|
||||
}
|
||||
}
|
||||
if (rawModel.id === "anthropic/claude-fable-5") {
|
||||
const claudeFable1mModelInfo = cloneDeep(modelInfo)
|
||||
claudeFable1mModelInfo.contextWindow = 1_000_000
|
||||
claudeFable1mModelInfo.tiers = CLAUDE_FABLE_1M_TIERS
|
||||
models[openRouterClaudeFable51mModelId] = claudeFable1mModelInfo
|
||||
}
|
||||
}
|
||||
// Save models and cache them in memory
|
||||
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
|
||||
|
||||
@@ -144,6 +144,22 @@ export const CLAUDE_OPUS_1M_TIERS = [
|
||||
cacheReadsPrice: 1.0,
|
||||
},
|
||||
]
|
||||
export const CLAUDE_FABLE_1M_TIERS = [
|
||||
{
|
||||
contextWindow: 200000,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
},
|
||||
{
|
||||
contextWindow: Number.MAX_SAFE_INTEGER,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
},
|
||||
]
|
||||
|
||||
export interface HicapCompatibleModelInfo extends ModelInfo {
|
||||
temperature?: number
|
||||
@@ -318,6 +334,29 @@ export const anthropicModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-fable-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
},
|
||||
"claude-fable-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
tiers: CLAUDE_FABLE_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -505,6 +544,17 @@ export const claudeCodeModels = {
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-fable-5": {
|
||||
...anthropicModels["claude-fable-5"],
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-fable-5[1m]": {
|
||||
...anthropicModels["claude-fable-5:1m"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
...anthropicModels["claude-opus-4-7"],
|
||||
contextWindow: 200_000,
|
||||
@@ -685,6 +735,31 @@ export const bedrockModels = {
|
||||
cacheReadsPrice: 0.5,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-fable-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
},
|
||||
"anthropic.claude-fable-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
tiers: CLAUDE_FABLE_1M_TIERS,
|
||||
},
|
||||
"anthropic.claude-opus-4-7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -922,6 +997,7 @@ export const openRouterClaudeSonnet461mModelId = `anthropic/claude-sonnet-4.6${C
|
||||
export const openRouterClaudeOpus461mModelId = `anthropic/claude-opus-4.6${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus471mModelId = `anthropic/claude-opus-4.7${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeOpus481mModelId = `anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterClaudeFable51mModelId = `anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`
|
||||
export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1207,6 +1283,31 @@ export const vertexModels = {
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_OPUS_1M_TIERS,
|
||||
},
|
||||
"claude-fable-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
"claude-fable-5:1m": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 10,
|
||||
outputPrice: 50,
|
||||
cacheWritesPrice: 12.5,
|
||||
cacheReadsPrice: 1,
|
||||
supportsReasoning: true,
|
||||
tiers: CLAUDE_FABLE_1M_TIERS,
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
|
||||
@@ -12,7 +12,10 @@ export function isClaudeOpusAdaptiveThinkingModel(modelId?: string): boolean {
|
||||
|
||||
const id = modelId.toLowerCase()
|
||||
const adaptiveVersions = ["4-6", "4.6", "4-7", "4.7", "4-8", "4.8"]
|
||||
return adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
|
||||
return (
|
||||
id.includes("claude-fable-5") ||
|
||||
adaptiveVersions.some((version) => id.includes(`claude-opus-${version}`) || id.includes(`claude-${version}-opus`))
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveClaudeOpusAdaptiveThinking(
|
||||
|
||||
@@ -512,6 +512,14 @@ const ClineModelPicker: React.FC<ClineModelPickerProps> = ({ isPopup, currentMod
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
|
||||
{/* Context window switcher for Claude Fable 5 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
base200kModelId="anthropic/claude-fable-5"
|
||||
onModelChange={handleModelChange}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
|
||||
{/* Context window switcher for Claude Opus 4.8 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
|
||||
@@ -323,6 +323,14 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
|
||||
)}
|
||||
</DropdownWrapper>
|
||||
|
||||
{/* Context window switcher for Claude Fable 5 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-fable-5${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
base200kModelId="anthropic/claude-fable-5"
|
||||
onModelChange={handleModelChange}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
|
||||
{/* Context window switcher for Claude Opus 4.8 */}
|
||||
<ContextWindowSwitcher
|
||||
base1mModelId={`anthropic/claude-opus-4.8${CLAUDE_SONNET_1M_SUFFIX}`}
|
||||
|
||||
@@ -13,6 +13,7 @@ const SUPPORTED_CLAUDE_CODE_THINKING_MODELS = [
|
||||
...SUPPORTED_ANTHROPIC_THINKING_MODELS,
|
||||
"sonnet",
|
||||
"sonnet[1m]",
|
||||
"claude-fable-5[1m]",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
|
||||
+105
-1
@@ -15,5 +15,109 @@
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": [
|
||||
"apps/vscode/**"
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "info",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 130,
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none",
|
||||
"expand": "always"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.20",
|
||||
"version": "3.0.22",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -371,7 +371,7 @@
|
||||
},
|
||||
"sdk/packages/agents": {
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*",
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"sdk/packages/core": {
|
||||
"name": "@cline/core",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/agents": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
@@ -411,7 +411,7 @@
|
||||
},
|
||||
"sdk/packages/llms": {
|
||||
"name": "@cline/llms",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "^4.0.89",
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
@@ -445,14 +445,14 @@
|
||||
},
|
||||
"sdk/packages/sdk": {
|
||||
"name": "@cline/sdk",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
},
|
||||
},
|
||||
"sdk/packages/shared": {
|
||||
"name": "@cline/shared",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"dependencies": {
|
||||
"aws4fetch": "^1.0.20",
|
||||
"jsonrepair": "^3.13.2",
|
||||
@@ -464,15 +464,15 @@
|
||||
"packages": {
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cH71k96tcnLuq1x3xi0KP384Jxio8qM6VQzHDUfU4OuX2P83FC/pBvksR5YVRm17GdQliAfR1t5o6z1iJRtfpA=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.122", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U1k2fk7cSH/tS5CZ3ujROiUCOLFwkzb792OqR/Org8Mfm27dKSIdRZG4ZuJUifT8alUWa61IoaRu4foXKlP5TQ=="],
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.123", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rL+3Sp9crOlfE7MwguFPS30qVp6HFcr9na0KYMb4CcQdxAIjBJec3EEdCjc94UyRVZWLmgQ6Yr605FmPlFIi0w=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.140", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Fz3STER9hcrY0uJ6wMg8tSasS5+FbLnBU9N89cw9KBkSUtq+Hefeert7y/UA7RUgPS+d6Z+kzmkqbdAU5dn+oA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UGXQeV+z30Gk1/mhKZoXKbYO7Q0U7pg0Nlz44hb2B1FpYRFBu8+ziaI60BdetncaoxPocdNKhP41AD15IA6nJg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KkdaMjs4C2y+vrZWJE990E3ZxBFiOTHQ94ZlquuIttpphcqJTMxNoIpnKT/4UzMVWXL0BUEE2vs+1UEVXkN8Kg=="],
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.196", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.194", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-e/XI6e5cY/FYAvd7ThQzA/zadmVLukwUPvLS9+i1ZoSjwcgTB5LyUnvljMY/+8w++aCp0EKEgrJ+jiL81UHpXQ=="],
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.197", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.195", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-sx5K0pvIWbgduMYGz++s28ldj+hu+GfDpXw9T2kL4n+bhRQpQvrH+jU0z3OqvjMrhD5oz9cGJ7bv/0JQEKXIbw=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
@@ -520,41 +520,41 @@
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1058.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5D3cnn3h72xc7oXLfqMIP81Z7H2Y+FXUq+YGSHQRfGTEvbH+m+5IuA1SwmEKIktobXwjpSuQW34eUKSnTgK1ng=="],
|
||||
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1059.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-HW3Oq2rL65tbuqkQzoRrjfF3jauRfra056Xv0K2YtBWt+LaeLrr95D8SlRmgGzXZHwy38FO/w0N1EKjsTYmDKw=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.974.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-WXPvTfG7J2H4Ae6ewhd0285UC+8+9p/pKoibXXQlbXSqHexFLGM0oXHTwDfQEPmrNnvuWPpVjgoAUfW+cUFbXw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.38", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ=="],
|
||||
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.39", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fp7hiew245BbBiaDGjLDaMXqxbOWnqzuhczWrJq/6/3gDNHtZvkorxHQXYpHYiddetR8sBWe3S49HfZ2BQbYSg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0+MCOYqeHyADFdeVr5/e1G8JJoWm7/szZAiKssqJS84E9+tO07509YNyQRJ5a7x5wO9YFAV324syrwm6yIcs5w=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-auuhqlnv4PUdfqcdHLKGTdoCceXOuby6WNeCMoxtZmQGWNCibbz95/lSYzNWq9cExN17UlRFqo1nvTcC+zHfEg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-G+8JuG0CfLcC2IQXFVqMR1+KDF1rksebr+YL6+HHYbNjs/hiwX53ye45sU3pJKljpS2uIXqOrOsicHIv02vWrw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+H6h2/1Q+iIJ4w+FPCKM//xwy4C9yADknDTR68+K3PbOtB/uLup3zIH8PUKn6QwnttME4VM4ftSTnbvoDf5wXg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.49", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hlyoc+2352BhC+HF91t9avIDJu1+EvQGGltxFB8ADCpAHGYNNLEQAZJIPzw3O/bRiiDSE2B8pdj/fFCXlDTEDg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-r996IYVtQ7rWa5UfSs3fZLT/Dq/SSgH9wv9zahx9lcg6vvaPPQHFpTy45nZajZu/+OUdEQxEjTn9rOMons59mA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/token-providers": "3.1059.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vd+UqRbiYLvXXH3kTAuTxq+Vdb3rOgg29/FeB35ETsgdJTTB7x3YuqtpRckATsL5bDRXFVQo0uBj0/vxCJ8hHg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xuxBbMorygYsbDV6E/tUGQUgIDfhnzxz8uJYX8rByzehI6gLUu8RPsgOpLmh9YWerqeHZxJbqzgheBSB7tpooQ=="],
|
||||
|
||||
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1058.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1058.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/5Yuf7w8GRz2lic2cg0gRvZzJA2OgiX8HYoMNWLbs3b1vJelz222w5VlKsxPP9D376Zv38E+VHJenvwFgKjK+Q=="],
|
||||
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1059.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-cognito-identity": "^3.972.39", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-cSdDFb/O3cQHGg78VxPaNruyy9zGxcoeS7DlwYTdwxmYkE0GXmBRoej5N+q+Av9YWBayZ2n3QsSYhtnUXa/GXw=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-T5CS1r4P27FjkBYIWwVibWqEuq32BbCga2Z5m5OBSdSdi2wPfW2vl6zLWAB/5MeeyC4s2pY/MY3cj2Gd3rgSkg=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xql+7YBAE7WYb9xJfY0vcAXM8rJXfClmB2wkt+g/EoLUMog0pOb7o741fE96wFqha0uQTEgTo/5lGGguzavxmw=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.973.10", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.27", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
|
||||
|
||||
@@ -1662,7 +1662,7 @@
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ai": ["ai@6.0.194", "", { "dependencies": { "@ai-sdk/gateway": "3.0.122", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0MkYqrSZZuC1zTECppcaUT0i54aocXpYaUMVue3V8z/weBHCytfO5/CcwZCU80msZpfkbBUKYSSrkZFotEO5wQ=="],
|
||||
"ai": ["ai@6.0.195", "", { "dependencies": { "@ai-sdk/gateway": "3.0.123", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IYZpuVz0boWbpIQYyinfWFrvQ1N0dG+EVB63it45B2YAU/MxxCnwz4zBswjYnPtHnJBedpMPNrwVbeczbl2GKg=="],
|
||||
|
||||
"ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.4.4", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.2.63" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-iHcup5SHh4Tul1RIi9J+bnpngen8WX66yC3lsz1YlbtwAmRhUEzZUuGKzmFGIN8Pmx9uQrerGfLJdbFxIxKkyw=="],
|
||||
|
||||
@@ -1856,7 +1856,7 @@
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="],
|
||||
"cytoscape": ["cytoscape@3.34.0", "", {}, "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg=="],
|
||||
|
||||
"cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
|
||||
|
||||
@@ -1996,7 +1996,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.366", "", {}, "sha512-OlRuhb688YTCzzU3gXPLn6nGyd+F+53INE1qaKKlu6kETErE8FYsyDh0XqXEU+uBRn0MpCzz2vfNwORhkap8qg=="],
|
||||
|
||||
"embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="],
|
||||
|
||||
@@ -2664,7 +2664,7 @@
|
||||
|
||||
"node-pty": ["node-pty@1.2.0-beta.11", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-THcUyu1WwdgoIyUvgXOZ70EOMXzheGa0q3tbEb5kUIfKgcpBJ+AJ9Q1kq0bKtYmQzr77usXiTORZTLmAUQlnoQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
|
||||
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
|
||||
|
||||
"node-rsa": ["node-rsa@1.1.1", "", { "dependencies": { "asn1": "^0.2.4" } }, "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw=="],
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Cline SDK Changelog
|
||||
|
||||
## 0.0.46
|
||||
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Added Vertex GCP settings configuration
|
||||
- Fixed the Azure Foundry API version for the CLI
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 0.0.45
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 0.0.44
|
||||
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/agents",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
|
||||
apiKey: "test-key",
|
||||
baseUrl: undefined,
|
||||
headers: undefined,
|
||||
options: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -82,6 +83,32 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes provider options through to the llms gateway", () => {
|
||||
const model = new ScriptedModel([]);
|
||||
createAgentModel.mockReturnValue(model);
|
||||
|
||||
new Agent({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "gpt-4.1",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
options: { apiVersion: "2025-01-01-preview" },
|
||||
});
|
||||
|
||||
expect(createGateway).toHaveBeenCalledWith({
|
||||
providerConfigs: [
|
||||
{
|
||||
providerId: "openai-compatible",
|
||||
apiKey: "test-key",
|
||||
baseUrl:
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
headers: undefined,
|
||||
options: { apiVersion: "2025-01-01-preview" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards abort() to the active AgentRuntime", async () => {
|
||||
let abortReason: unknown;
|
||||
const model = new ScriptedModel([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createGateway } from "@cline/llms";
|
||||
import { createGateway, type GatewayProviderSettings } from "@cline/llms";
|
||||
import type {
|
||||
AgentAfterToolResult,
|
||||
AgentBeforeModelResult,
|
||||
@@ -64,6 +64,8 @@ export interface AgentRuntimeConfigWithProvider
|
||||
baseUrl?: string;
|
||||
/** Additional headers for API requests */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider-specific gateway options */
|
||||
options?: GatewayProviderSettings["options"];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,9 +90,10 @@ function resolveRuntimeConfig(
|
||||
if (hasPrebuiltModel(config)) {
|
||||
return config;
|
||||
}
|
||||
const { providerId, modelId, apiKey, baseUrl, headers, ...rest } = config;
|
||||
const { providerId, modelId, apiKey, baseUrl, headers, options, ...rest } =
|
||||
config;
|
||||
const gateway = createGateway({
|
||||
providerConfigs: [{ providerId, apiKey, baseUrl, headers }],
|
||||
providerConfigs: [{ providerId, apiKey, baseUrl, headers, options }],
|
||||
telemetry: rest.telemetry,
|
||||
});
|
||||
const model = gateway.createAgentModel({ providerId, modelId });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/core",
|
||||
"description": "Cline Core SDK for Node Runtime",
|
||||
"version": "0.0.44",
|
||||
"version": "0.0.46",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
|
||||
@@ -22,7 +22,6 @@ const socketBindingSupported = await (async () => {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
const _socketIt = socketBindingSupported ? it : it.skip;
|
||||
|
||||
function createCredentials(
|
||||
overrides: Partial<ClineOAuthCredentials> = {},
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import {
|
||||
isCredentialLikelyExpired,
|
||||
parseAuthorizationInput,
|
||||
@@ -701,22 +697,3 @@ export async function getValidClineCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createClineOAuthProvider(
|
||||
options: ClineOAuthProviderOptions,
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "cline",
|
||||
name: "Cline Account",
|
||||
usesCallbackServer: !(options.useWorkOSDeviceAuth ?? true),
|
||||
async login(callbacks) {
|
||||
return loginClineOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshClineToken(credentials as ClineOAuthCredentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return `workos:${credentials.access}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getValidOpenAICodexCredentials,
|
||||
normalizeOpenAICodexCredentials,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./codex";
|
||||
import type { OAuthCredentials } from "./types";
|
||||
@@ -138,19 +137,6 @@ describe("auth/codex token lifecycle", () => {
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("normalizes credentials by deriving accountId from access token", () => {
|
||||
const accessToken = createJwt({
|
||||
"https://api.openai.com/auth": { chatgpt_account_id: "acct-derived" },
|
||||
});
|
||||
const normalized = normalizeOpenAICodexCredentials({
|
||||
access: accessToken,
|
||||
refresh: "refresh",
|
||||
expires: 1,
|
||||
});
|
||||
expect(normalized.accountId).toBe("acct-derived");
|
||||
expect(normalized.metadata).toMatchObject({ provider: "openai-codex" });
|
||||
});
|
||||
|
||||
it("refreshOpenAICodexToken throws when response is structurally invalid", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
identifyAccount,
|
||||
} from "../services/telemetry/core-events";
|
||||
import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
import type { OAuthCredentials, OAuthPrompt } from "./types";
|
||||
import {
|
||||
decodeJwtPayload,
|
||||
getProofKey,
|
||||
@@ -442,50 +437,3 @@ export async function getValidOpenAICodexCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAICodexTokenExpired(
|
||||
credentials: OAuthCredentials,
|
||||
refreshBufferMs: number = OPENAI_CODEX_OAUTH_CONFIG.refreshBufferMs,
|
||||
): boolean {
|
||||
return isCredentialLikelyExpired(credentials, refreshBufferMs);
|
||||
}
|
||||
|
||||
export function normalizeOpenAICodexCredentials(
|
||||
credentials: OAuthCredentials,
|
||||
): OAuthCredentials {
|
||||
const accountId = credentials.accountId ?? getAccountId(credentials.access);
|
||||
if (!accountId) {
|
||||
throw new Error("Failed to extract accountId from token");
|
||||
}
|
||||
return {
|
||||
...credentials,
|
||||
accountId,
|
||||
metadata: {
|
||||
...(credentials.metadata ?? {}),
|
||||
provider: "openai-codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
|
||||
id: "openai-codex",
|
||||
name: "ChatGPT Plus/Pro (ChatGPT Subscription)",
|
||||
usesCallbackServer: true,
|
||||
|
||||
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
||||
return loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
});
|
||||
},
|
||||
|
||||
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
||||
return refreshOpenAICodexToken(credentials.refresh, credentials);
|
||||
},
|
||||
|
||||
getApiKey(credentials: OAuthCredentials): string {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,8 +12,6 @@ import { startLocalOAuthServer } from "./server";
|
||||
import type {
|
||||
OAuthCredentials,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthProviderInterface,
|
||||
OcaClientMetadata,
|
||||
OcaMode,
|
||||
OcaOAuthConfig,
|
||||
OcaOAuthProviderOptions,
|
||||
@@ -525,64 +523,3 @@ export async function getValidOcaCredentials(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createOcaOAuthProvider(
|
||||
options: OcaOAuthProviderOptions = {},
|
||||
): OAuthProviderInterface {
|
||||
return {
|
||||
id: "oca",
|
||||
name: "Oracle Code Assist",
|
||||
usesCallbackServer: true,
|
||||
async login(callbacks) {
|
||||
return loginOcaOAuth({ ...options, callbacks });
|
||||
},
|
||||
async refreshToken(credentials) {
|
||||
return refreshOcaToken(credentials, options);
|
||||
},
|
||||
getApiKey(credentials) {
|
||||
return credentials.access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateOcaOpcRequestId(
|
||||
taskId: string,
|
||||
token: string,
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const hash8 = async (value: string): Promise<string> => {
|
||||
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
|
||||
return Array.from(new Uint8Array(digest).slice(0, 4), (byte) =>
|
||||
byte.toString(16).padStart(2, "0"),
|
||||
).join("");
|
||||
};
|
||||
|
||||
const [tokenHex, taskHex] = await Promise.all([hash8(token), hash8(taskId)]);
|
||||
const timestampHex = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const randomPart = new Uint32Array(1);
|
||||
crypto.getRandomValues(randomPart);
|
||||
const randomHex = (randomPart[0] ?? 0).toString(16).padStart(8, "0");
|
||||
return tokenHex + taskHex + timestampHex + randomHex;
|
||||
}
|
||||
|
||||
export async function createOcaRequestHeaders(input: {
|
||||
accessToken: string;
|
||||
taskId: string;
|
||||
metadata?: OcaClientMetadata;
|
||||
}): Promise<Record<string, string>> {
|
||||
const opcRequestId = await generateOcaOpcRequestId(
|
||||
input.taskId,
|
||||
input.accessToken,
|
||||
);
|
||||
return {
|
||||
Authorization: `Bearer ${input.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
client: input.metadata?.client ?? "Cline",
|
||||
"client-version": input.metadata?.clientVersion ?? "unknown",
|
||||
"client-ide": input.metadata?.clientIde ?? "unknown",
|
||||
"client-ide-version": input.metadata?.clientIdeVersion ?? "unknown",
|
||||
[OCI_HEADER_OPC_REQUEST_ID]: opcRequestId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
} from "./provider-auth-registry";
|
||||
|
||||
const { loginClineOAuth } = vi.hoisted(() => ({
|
||||
loginClineOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cline", () => ({
|
||||
getValidClineCredentials: vi.fn(),
|
||||
loginClineOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./oca", () => ({
|
||||
getValidOcaCredentials: vi.fn(),
|
||||
loginOcaOAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./codex", () => ({
|
||||
getValidOpenAICodexCredentials: vi.fn(),
|
||||
loginOpenAICodex: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("provider auth registry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns handlers for managed OAuth providers only", () => {
|
||||
expect(getProviderAuthHandler("cline")?.providerId).toBe("cline");
|
||||
expect(getProviderAuthHandler("oca")?.providerId).toBe("oca");
|
||||
expect(getProviderAuthHandler("openai-codex")?.providerId).toBe(
|
||||
"openai-codex",
|
||||
);
|
||||
expect(getProviderAuthHandler("openai-codex-cli")).toBeUndefined();
|
||||
expect(isOAuthProvider("openai-codex-cli")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns storage provider IDs from handlers", () => {
|
||||
expect(getProviderAuthStorageId("cline")).toBe("cline");
|
||||
expect(getProviderAuthStorageId("oca")).toBe("oca");
|
||||
expect(getProviderAuthStorageId("openai-codex")).toBe("openai-codex");
|
||||
expect(getProviderAuthStorageId("openai-codex-cli")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("formats Cline WorkOS tokens without double-prefixing", () => {
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(formatProviderOAuthApiKey("cline", { access: "workos:abc" })).toBe(
|
||||
"workos:abc",
|
||||
);
|
||||
expect(
|
||||
getPersistedProviderApiKey("cline", {
|
||||
provider: "cline",
|
||||
auth: { accessToken: "abc" },
|
||||
}),
|
||||
).toBe("workos:abc");
|
||||
});
|
||||
|
||||
it("login/save stores credentials under handler storageProviderId", async () => {
|
||||
loginClineOAuth.mockResolvedValueOnce({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
const getProviderSettings = vi.fn().mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
});
|
||||
const saveProviderSettings = vi.fn();
|
||||
const manager = {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
} as never;
|
||||
|
||||
const saved = await loginAndSaveProviderOAuthCredentials(manager, "cline", {
|
||||
callbacks: {
|
||||
onAuth: vi.fn(),
|
||||
onPrompt: vi.fn(async () => ""),
|
||||
},
|
||||
});
|
||||
|
||||
expect(getProviderSettings).toHaveBeenCalledWith("cline");
|
||||
expect(saved).toMatchObject({
|
||||
provider: "cline",
|
||||
apiKey: "manual-key",
|
||||
auth: {
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
},
|
||||
});
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "cline" }),
|
||||
{ tokenSource: "oauth" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
getClineEnvironmentConfig,
|
||||
type ITelemetryService,
|
||||
} from "@cline/shared";
|
||||
import type { ProviderSettingsManager } from "../services/storage/provider-settings-manager";
|
||||
import type { ProviderSettings } from "../types/provider-settings";
|
||||
import {
|
||||
type ClineOAuthCredentials,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
} from "./cline";
|
||||
import { getValidOpenAICodexCredentials, loginOpenAICodex } from "./codex";
|
||||
import { getValidOcaCredentials, loginOcaOAuth } from "./oca";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
|
||||
import { decodeJwtPayload } from "./utils";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
|
||||
export type ProviderOAuthCredentials = OAuthCredentials;
|
||||
|
||||
export interface ProviderAuthLoginInput {
|
||||
settings?: ProviderSettings;
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthRefreshInput {
|
||||
settings: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
forceRefresh?: boolean;
|
||||
telemetry?: ITelemetryService;
|
||||
}
|
||||
|
||||
export interface ProviderAuthSaveCredentialsInput {
|
||||
manager: ProviderSettingsManager;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuthHandler {
|
||||
providerId: string;
|
||||
storageProviderId: string;
|
||||
getApiKey(settings: ProviderSettings | undefined): string | undefined;
|
||||
login(input: ProviderAuthLoginInput): Promise<ProviderOAuthCredentials>;
|
||||
refresh(
|
||||
input: ProviderAuthRefreshInput,
|
||||
): Promise<ProviderOAuthCredentials | null>;
|
||||
saveCredentials(input: ProviderAuthSaveCredentialsInput): ProviderSettings;
|
||||
isConfigured(settings: ProviderSettings | undefined): boolean;
|
||||
normalizeStoredAccessToken?(accessToken: string): string;
|
||||
}
|
||||
|
||||
function formatClineApiKey(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token
|
||||
: `${WORKOS_TOKEN_PREFIX}${token}`;
|
||||
}
|
||||
|
||||
function stripClineApiKeyPrefix(accessToken: string): string {
|
||||
const token = accessToken.trim();
|
||||
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? token.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: token;
|
||||
}
|
||||
|
||||
function readExpiryFromToken(accessToken: string): number | null {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
const exp = payload?.exp;
|
||||
if (typeof exp === "number" && exp > 0) {
|
||||
return exp * 1000;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCredentialExpiry(
|
||||
settings: ProviderSettings,
|
||||
normalizedAccessToken: string,
|
||||
): number {
|
||||
const explicitExpiry = settings.auth?.expiresAt;
|
||||
if (
|
||||
typeof explicitExpiry === "number" &&
|
||||
Number.isFinite(explicitExpiry) &&
|
||||
explicitExpiry > 0
|
||||
) {
|
||||
return explicitExpiry;
|
||||
}
|
||||
|
||||
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
|
||||
if (jwtExpiry) {
|
||||
return jwtExpiry;
|
||||
}
|
||||
|
||||
// Unknown expiry should trigger refresh on next resolution.
|
||||
return Date.now() - 1;
|
||||
}
|
||||
|
||||
function createCredentialsFromSettings(
|
||||
settings: ProviderSettings,
|
||||
options?: { normalizeAccessToken?: (accessToken: string) => string },
|
||||
): ProviderOAuthCredentials | null {
|
||||
const rawAccess = settings.auth?.accessToken?.trim();
|
||||
const refreshToken = settings.auth?.refreshToken?.trim();
|
||||
if (!rawAccess || !refreshToken) {
|
||||
return null;
|
||||
}
|
||||
const access = options?.normalizeAccessToken?.(rawAccess) ?? rawAccess;
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
access,
|
||||
refresh: refreshToken,
|
||||
expires: deriveCredentialExpiry(settings, access),
|
||||
accountId: settings.auth?.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function saveOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
storageProviderId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const accessToken =
|
||||
input.formatAccessToken?.(input.credentials.access) ??
|
||||
input.credentials.access;
|
||||
const auth = {
|
||||
...(input.settings?.auth ?? {}),
|
||||
accessToken,
|
||||
refreshToken: input.credentials.refresh,
|
||||
accountId: input.credentials.accountId,
|
||||
expiresAt: input.credentials.expires,
|
||||
};
|
||||
|
||||
const merged: ProviderSettings = {
|
||||
...(input.settings ?? {
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: input.storageProviderId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
if (input.save !== false) {
|
||||
input.manager.saveProviderSettings(merged, {
|
||||
...(input.setLastUsed === undefined
|
||||
? {}
|
||||
: { setLastUsed: input.setLastUsed }),
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function createOAuthHandler(input: {
|
||||
providerId: string;
|
||||
storageProviderId?: string;
|
||||
formatAccessToken?: (accessToken: string) => string;
|
||||
normalizeStoredAccessToken?: (accessToken: string) => string;
|
||||
login: (input: ProviderAuthLoginInput) => Promise<ProviderOAuthCredentials>;
|
||||
refresh: (
|
||||
input: ProviderAuthRefreshInput,
|
||||
) => Promise<ProviderOAuthCredentials | null>;
|
||||
}): ProviderAuthHandler {
|
||||
const storageProviderId = input.storageProviderId ?? input.providerId;
|
||||
return {
|
||||
providerId: input.providerId,
|
||||
storageProviderId,
|
||||
getApiKey(settings) {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return input.formatAccessToken?.(accessToken) ?? accessToken;
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.apiKey?.trim() || settings?.auth?.apiKey?.trim() || undefined
|
||||
);
|
||||
},
|
||||
login: input.login,
|
||||
refresh: input.refresh,
|
||||
saveCredentials(saveInput) {
|
||||
return saveOAuthCredentials({
|
||||
...saveInput,
|
||||
storageProviderId,
|
||||
formatAccessToken: input.formatAccessToken,
|
||||
});
|
||||
},
|
||||
isConfigured(settings) {
|
||||
return !!settings?.auth?.accessToken;
|
||||
},
|
||||
normalizeStoredAccessToken: input.normalizeStoredAccessToken,
|
||||
};
|
||||
}
|
||||
|
||||
const providerAuthHandlers = [
|
||||
createOAuthHandler({
|
||||
providerId: "cline",
|
||||
formatAccessToken: formatClineApiKey,
|
||||
normalizeStoredAccessToken: stripClineApiKeyPrefix,
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidClineCredentials(
|
||||
credentials as ClineOAuthCredentials,
|
||||
{
|
||||
apiBaseUrl:
|
||||
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
telemetry,
|
||||
},
|
||||
{ forceRefresh },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "oca",
|
||||
login: ({ settings, callbacks, telemetry }) =>
|
||||
loginOcaOAuth({ mode: settings?.oca?.mode, callbacks, telemetry }),
|
||||
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
|
||||
getValidOcaCredentials(
|
||||
credentials,
|
||||
{ forceRefresh, telemetry },
|
||||
{ mode: settings.oca?.mode, telemetry },
|
||||
),
|
||||
}),
|
||||
createOAuthHandler({
|
||||
providerId: "openai-codex",
|
||||
login: ({ callbacks, telemetry }) =>
|
||||
loginOpenAICodex({
|
||||
onAuth: callbacks.onAuth,
|
||||
onPrompt: callbacks.onPrompt,
|
||||
onProgress: callbacks.onProgress,
|
||||
onManualCodeInput: callbacks.onManualCodeInput,
|
||||
telemetry,
|
||||
}),
|
||||
refresh: ({ credentials, forceRefresh, telemetry }) =>
|
||||
getValidOpenAICodexCredentials(credentials, { forceRefresh, telemetry }),
|
||||
}),
|
||||
] as const satisfies readonly ProviderAuthHandler[];
|
||||
|
||||
const providerAuthHandlerById = new Map<string, ProviderAuthHandler>(
|
||||
providerAuthHandlers.map((handler) => [handler.providerId, handler]),
|
||||
);
|
||||
|
||||
export function getProviderAuthHandler(
|
||||
providerId: string,
|
||||
): ProviderAuthHandler | undefined {
|
||||
return providerAuthHandlerById.get(providerId.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function isOAuthProvider(providerId: string): boolean {
|
||||
return getProviderAuthHandler(providerId) !== undefined;
|
||||
}
|
||||
|
||||
export function getProviderAuthStorageId(
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
return getProviderAuthHandler(providerId)?.storageProviderId;
|
||||
}
|
||||
|
||||
export function resolveProviderApiKeyFromSettings(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
const storageProviderId = handler?.storageProviderId ?? providerId;
|
||||
const settings = manager.getProviderSettings(storageProviderId);
|
||||
return (
|
||||
handler?.getApiKey(settings) ??
|
||||
getPersistedProviderApiKey(providerId, settings)
|
||||
);
|
||||
}
|
||||
|
||||
export async function loginAndSaveProviderOAuthCredentials(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
input: {
|
||||
callbacks: OAuthLoginCallbacks;
|
||||
telemetry?: ITelemetryService;
|
||||
},
|
||||
): Promise<ProviderSettings> {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) {
|
||||
throw new Error(`Provider "${providerId}" does not support OAuth login`);
|
||||
}
|
||||
const existing = manager.getProviderSettings(handler.storageProviderId);
|
||||
const credentials = await handler.login({
|
||||
settings: existing,
|
||||
callbacks: input.callbacks,
|
||||
telemetry: input.telemetry,
|
||||
});
|
||||
return handler.saveCredentials({ manager, settings: existing, credentials });
|
||||
}
|
||||
|
||||
export function getProviderOAuthCredentialsFromSettings(
|
||||
providerId: string,
|
||||
settings: ProviderSettings,
|
||||
): ProviderOAuthCredentials | null {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return null;
|
||||
return createCredentialsFromSettings(settings, {
|
||||
normalizeAccessToken: handler.normalizeStoredAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function saveProviderOAuthCredentials(input: {
|
||||
manager: ProviderSettingsManager;
|
||||
providerId: string;
|
||||
settings?: ProviderSettings;
|
||||
credentials: ProviderOAuthCredentials;
|
||||
setLastUsed?: boolean;
|
||||
save?: boolean;
|
||||
}): ProviderSettings {
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
if (!handler) {
|
||||
throw new Error(
|
||||
`Provider "${input.providerId}" does not support OAuth credentials`,
|
||||
);
|
||||
}
|
||||
return handler.saveCredentials({
|
||||
manager: input.manager,
|
||||
settings: input.settings,
|
||||
credentials: input.credentials,
|
||||
setLastUsed: input.setLastUsed,
|
||||
save: input.save,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (handler) {
|
||||
return handler.getApiKey(settings);
|
||||
}
|
||||
|
||||
return (
|
||||
settings?.auth?.accessToken?.trim() ||
|
||||
settings?.apiKey?.trim() ||
|
||||
settings?.auth?.apiKey?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function formatProviderOAuthApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<ProviderOAuthCredentials, "access">,
|
||||
): string {
|
||||
const handler = getProviderAuthHandler(providerId);
|
||||
if (!handler) return credentials.access;
|
||||
|
||||
return (
|
||||
handler.getApiKey({
|
||||
provider: handler.storageProviderId,
|
||||
auth: { accessToken: credentials.access },
|
||||
}) ?? credentials.access
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentExtension } from "@cline/shared";
|
||||
import type { SkillsExecutorWithMetadata } from "../tools";
|
||||
import {
|
||||
type AvailableRuntimeCommand,
|
||||
listAvailableRuntimeCommandsFromWatcher,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import {
|
||||
type CreateUserInstructionPluginOptions,
|
||||
createUserInstructionPlugin,
|
||||
createUserInstructionSkillsExecutor,
|
||||
getConfiguredSkillsFromWatcher,
|
||||
} from "./user-instruction-plugin";
|
||||
|
||||
@@ -39,6 +41,9 @@ export interface UserInstructionConfigService {
|
||||
listRuntimeCommands(): AvailableRuntimeCommand[];
|
||||
resolveRuntimeSlashCommand(input: string): string;
|
||||
hasConfiguredSkills(allowedSkillNames?: ReadonlyArray<string>): boolean;
|
||||
createSkillsExecutor?(
|
||||
allowedSkillNames?: ReadonlyArray<string>,
|
||||
): SkillsExecutorWithMetadata;
|
||||
createExtension(
|
||||
options: Omit<
|
||||
CreateUserInstructionPluginOptions,
|
||||
@@ -107,6 +112,16 @@ class DefaultUserInstructionConfigService
|
||||
);
|
||||
}
|
||||
|
||||
createSkillsExecutor(
|
||||
allowedSkillNames?: ReadonlyArray<string>,
|
||||
): SkillsExecutorWithMetadata {
|
||||
return createUserInstructionSkillsExecutor(
|
||||
this.watcher,
|
||||
(this.ready ?? Promise.resolve()).catch(() => {}),
|
||||
allowedSkillNames,
|
||||
);
|
||||
}
|
||||
|
||||
createExtension(
|
||||
options: Omit<
|
||||
CreateUserInstructionPluginOptions,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseConfiguredAgentConfig } from "./configured-agent-config";
|
||||
|
||||
describe("configured agent config parser", () => {
|
||||
it("parses YAML frontmatter and system prompt body", () => {
|
||||
const config = parseConfiguredAgentConfig(`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: execute_command, read_file
|
||||
skills:
|
||||
- review-pr
|
||||
modelId: anthropic/claude-sonnet-4.6
|
||||
---
|
||||
You are a code reviewer.`);
|
||||
|
||||
expect(config).toMatchObject({
|
||||
name: "code-reviewer",
|
||||
description: "Reviews code",
|
||||
tools: ["execute_command", "read_file"],
|
||||
skills: ["review-pr"],
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
systemPrompt: "You are a code reviewer.",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat delimiter lines in the body as frontmatter delimiters", () => {
|
||||
const config = parseConfiguredAgentConfig(`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: read_file
|
||||
---
|
||||
Prompt body
|
||||
---
|
||||
More prompt`);
|
||||
|
||||
expect(config.systemPrompt).toBe("Prompt body\n---\nMore prompt");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty", ""],
|
||||
["comment-only", "# comment"],
|
||||
["scalar", "code-reviewer"],
|
||||
])("rejects %s frontmatter candidates without hanging", (_name, yaml) => {
|
||||
expect(() =>
|
||||
parseConfiguredAgentConfig(`---
|
||||
${yaml}
|
||||
---
|
||||
You are a code reviewer.`),
|
||||
).toThrow("Missing closing YAML frontmatter delimiter");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import { resolveAgentConfigSearchPaths } from "@cline/shared/storage";
|
||||
import YAML from "yaml";
|
||||
import { z } from "zod";
|
||||
|
||||
const ConfiguredAgentFrontmatterSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
tools: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
skills: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
providerId: z.string().trim().min(1).optional(),
|
||||
modelId: z.string().trim().min(1).optional(),
|
||||
maxIterations: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export interface ConfiguredAgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
skills?: string[];
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
maxIterations?: number;
|
||||
systemPrompt: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentReadError {
|
||||
path: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentLoadResult {
|
||||
configs: ConfiguredAgentConfig[];
|
||||
errors: ConfiguredAgentReadError[];
|
||||
}
|
||||
|
||||
function splitFrontmatter(content: string): {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
} {
|
||||
const firstLineMatch = content.match(/^(---)[^\S\r\n]*(?:\r?\n|$)/);
|
||||
if (!firstLineMatch) {
|
||||
throw new Error("Missing YAML frontmatter block in agent config file.");
|
||||
}
|
||||
|
||||
const frontmatterStart = firstLineMatch[0].length;
|
||||
const delimiterPattern = /^---[^\S\r\n]*(?:\r?\n|$)/gm;
|
||||
delimiterPattern.lastIndex = frontmatterStart;
|
||||
let lastValid:
|
||||
| {
|
||||
frontmatter: string;
|
||||
body: string;
|
||||
}
|
||||
| undefined;
|
||||
const candidates = Array.from(content.matchAll(delimiterPattern)).filter(
|
||||
(candidate) => candidate.index >= frontmatterStart,
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
const delimiterStart = candidate.index;
|
||||
const frontmatter = content.slice(frontmatterStart, delimiterStart);
|
||||
try {
|
||||
const parsedYaml = YAML.parse(frontmatter);
|
||||
if (
|
||||
!parsedYaml ||
|
||||
typeof parsedYaml !== "object" ||
|
||||
Array.isArray(parsedYaml)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
|
||||
const body = content.slice(delimiterStart + candidate[0].length);
|
||||
lastValid = { frontmatter, body };
|
||||
} catch {
|
||||
// Keep scanning: this delimiter may be literal content inside YAML.
|
||||
}
|
||||
}
|
||||
|
||||
if (lastValid) {
|
||||
return lastValid;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Missing closing YAML frontmatter delimiter in agent config file.",
|
||||
);
|
||||
}
|
||||
|
||||
function parseStringList(
|
||||
value: string | string[] | undefined,
|
||||
): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Array.isArray(value) ? value : value.split(",");
|
||||
return Array.from(
|
||||
new Set(
|
||||
raw.map((entry) => entry.trim()).filter((entry) => entry.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAgentName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isYamlFile(fileName: string): boolean {
|
||||
const extension = extname(fileName).toLowerCase();
|
||||
return extension === ".yml" || extension === ".yaml";
|
||||
}
|
||||
|
||||
export function parseConfiguredAgentConfig(
|
||||
content: string,
|
||||
options: { path?: string } = {},
|
||||
): ConfiguredAgentConfig {
|
||||
const { frontmatter, body } = splitFrontmatter(content);
|
||||
const parsedYaml = YAML.parse(frontmatter);
|
||||
if (
|
||||
!parsedYaml ||
|
||||
typeof parsedYaml !== "object" ||
|
||||
Array.isArray(parsedYaml)
|
||||
) {
|
||||
throw new Error("Agent config frontmatter must be a YAML mapping.");
|
||||
}
|
||||
|
||||
const parsed = ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
|
||||
const systemPrompt = body.trim();
|
||||
if (!systemPrompt) {
|
||||
throw new Error("Missing system prompt body in agent config file.");
|
||||
}
|
||||
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
tools: parseStringList(parsed.tools),
|
||||
skills: parseStringList(parsed.skills),
|
||||
providerId: parsed.providerId,
|
||||
modelId: parsed.modelId,
|
||||
maxIterations: parsed.maxIterations,
|
||||
systemPrompt,
|
||||
path: options.path,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfiguredAgentConfigs(input: {
|
||||
workspaceRoot?: string;
|
||||
searchPaths?: string[];
|
||||
}): ConfiguredAgentLoadResult {
|
||||
const searchPaths =
|
||||
input.searchPaths ?? resolveAgentConfigSearchPaths(input.workspaceRoot);
|
||||
const configsByName = new Map<string, ConfiguredAgentConfig>();
|
||||
const errors: ConfiguredAgentReadError[] = [];
|
||||
|
||||
for (const directory of searchPaths.filter(Boolean)) {
|
||||
if (!existsSync(directory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
path: directory,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !isYamlFile(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(directory, entry.name);
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const config = parseConfiguredAgentConfig(raw, { path: filePath });
|
||||
const normalizedName = normalizeAgentName(config.name);
|
||||
if (!configsByName.has(normalizedName)) {
|
||||
configsByName.set(normalizedName, config);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const configs = Array.from(configsByName.values()).sort((a, b) =>
|
||||
(a.path ? basename(a.path) : a.name).localeCompare(
|
||||
b.path ? basename(b.path) : b.name,
|
||||
),
|
||||
);
|
||||
return { configs, errors };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildConfiguredAgentToolName,
|
||||
createConfiguredAgentTools,
|
||||
} from "./configured-agent-tool";
|
||||
|
||||
describe("configured agent tools", () => {
|
||||
it("builds stable subagent tool names", () => {
|
||||
expect(buildConfiguredAgentToolName("Code Reviewer")).toBe(
|
||||
"subagent_code_reviewer",
|
||||
);
|
||||
expect(buildConfiguredAgentToolName("___")).toBe("subagent_agent");
|
||||
});
|
||||
|
||||
it("matches spawn_agent timeout and retry policy", () => {
|
||||
const [tool] = createConfiguredAgentTools({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () => ({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
}),
|
||||
getConnectionConfig: () => ({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
}),
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
name: "code-reviewer",
|
||||
description: "Reviews code",
|
||||
systemPrompt: "You are a code reviewer.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tool?.name).toBe("subagent_code_reviewer");
|
||||
expect(tool?.timeoutMs).toBe(300000);
|
||||
expect(tool?.retryable).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentResult,
|
||||
type AgentTool,
|
||||
type AgentToolContext,
|
||||
createTool,
|
||||
type HookErrorMode,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
type ToolPolicy,
|
||||
zodToJsonSchema,
|
||||
} from "@cline/shared";
|
||||
import { z } from "zod";
|
||||
import type { ConfiguredAgentConfig } from "./configured-agent-config";
|
||||
import {
|
||||
createDelegatedAgent,
|
||||
createDelegatedAgentConfigProvider,
|
||||
type DelegatedAgentConfigProvider,
|
||||
type DelegatedAgentRuntimeConfig,
|
||||
} from "./delegated-agent";
|
||||
import type {
|
||||
SpawnAgentOutput,
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "./spawn-agent-tool";
|
||||
|
||||
const CONFIGURED_AGENT_TOOL_NAME_PREFIX = "subagent_";
|
||||
const CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH = 64;
|
||||
|
||||
const ConfiguredAgentInputSchema = z.object({
|
||||
prompt: z.string().trim().min(1).describe("Task for the subagent to perform"),
|
||||
});
|
||||
|
||||
export type ConfiguredAgentInput = z.infer<typeof ConfiguredAgentInputSchema>;
|
||||
|
||||
export interface ConfiguredAgentToolDescriptor {
|
||||
toolName: string;
|
||||
config: ConfiguredAgentConfig;
|
||||
}
|
||||
|
||||
export interface ConfiguredAgentToolConfig {
|
||||
configProvider: DelegatedAgentConfigProvider;
|
||||
agents: ConfiguredAgentConfig[];
|
||||
createSubAgentTools?: (
|
||||
agent: ConfiguredAgentConfig,
|
||||
input: ConfiguredAgentInput,
|
||||
context: AgentToolContext,
|
||||
) => AgentTool[] | Promise<AgentTool[]>;
|
||||
onSubAgentEvent?: (event: AgentEvent) => void;
|
||||
hookErrorMode?: HookErrorMode;
|
||||
toolPolicies?: Record<string, ToolPolicy>;
|
||||
requestToolApproval?: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult> | ToolApprovalResult;
|
||||
onSubAgentStart?: (context: SubAgentStartContext) => void | Promise<void>;
|
||||
onSubAgentEnd?: (context: SubAgentEndContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function sanitizeAgentName(name: string): string {
|
||||
let result = "";
|
||||
let lastWasUnderscore = true;
|
||||
|
||||
for (const char of name.trim().toLowerCase()) {
|
||||
const code = char.charCodeAt(0);
|
||||
const isAllowed =
|
||||
(code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === "_";
|
||||
|
||||
if (!isAllowed || char === "_") {
|
||||
if (!lastWasUnderscore) {
|
||||
result += "_";
|
||||
lastWasUnderscore = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
lastWasUnderscore = false;
|
||||
}
|
||||
|
||||
return lastWasUnderscore ? result.slice(0, -1) : result;
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash ^= value.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function buildConfiguredAgentToolName(agentName: string): string {
|
||||
const sanitized = sanitizeAgentName(agentName) || "agent";
|
||||
const hashSuffix = hashString(agentName).slice(0, 6);
|
||||
const base = `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${sanitized}`;
|
||||
|
||||
if (base.length <= CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const maxBodyLength =
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH -
|
||||
CONFIGURED_AGENT_TOOL_NAME_PREFIX.length -
|
||||
hashSuffix.length -
|
||||
1;
|
||||
const body = sanitized.slice(0, Math.max(1, maxBodyLength));
|
||||
return `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${body}_${hashSuffix}`.slice(
|
||||
0,
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildConfiguredAgentToolDescriptors(
|
||||
agents: readonly ConfiguredAgentConfig[],
|
||||
): ConfiguredAgentToolDescriptor[] {
|
||||
const usedToolNames = new Set<string>();
|
||||
const descriptors: ConfiguredAgentToolDescriptor[] = [];
|
||||
|
||||
for (const config of [...agents].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)) {
|
||||
const baseName = buildConfiguredAgentToolName(config.name);
|
||||
let candidate = baseName;
|
||||
let suffix = 2;
|
||||
while (usedToolNames.has(candidate)) {
|
||||
const suffixText = `_${suffix++}`;
|
||||
const maxBaseLength = Math.max(
|
||||
1,
|
||||
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH - suffixText.length,
|
||||
);
|
||||
candidate = `${baseName.slice(0, maxBaseLength)}${suffixText}`;
|
||||
}
|
||||
usedToolNames.add(candidate);
|
||||
descriptors.push({ toolName: candidate, config });
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
function buildAgentRuntimeConfig(
|
||||
base: DelegatedAgentRuntimeConfig,
|
||||
agent: ConfiguredAgentConfig,
|
||||
): DelegatedAgentRuntimeConfig {
|
||||
return {
|
||||
...base,
|
||||
providerId: agent.providerId ?? base.providerId,
|
||||
modelId: agent.modelId ?? base.modelId,
|
||||
maxIterations: agent.maxIterations ?? base.maxIterations,
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfiguredAgentTools(
|
||||
options: ConfiguredAgentToolConfig,
|
||||
): AgentTool[] {
|
||||
return buildConfiguredAgentToolDescriptors(options.agents).map(
|
||||
({ toolName, config }) => {
|
||||
const tool = createTool<ConfiguredAgentInput, SpawnAgentOutput>({
|
||||
name: toolName,
|
||||
description: `Use the "${config.name}" subagent: ${config.description}`,
|
||||
inputSchema: zodToJsonSchema(ConfiguredAgentInputSchema),
|
||||
execute: async (input, context) => {
|
||||
const baseRuntimeConfig = options.configProvider.getRuntimeConfig();
|
||||
const configProvider = createDelegatedAgentConfigProvider(
|
||||
buildAgentRuntimeConfig(baseRuntimeConfig, config),
|
||||
);
|
||||
const tools = options.createSubAgentTools
|
||||
? await options.createSubAgentTools(config, input, context)
|
||||
: [];
|
||||
const subAgent = createDelegatedAgent({
|
||||
kind: "subagent",
|
||||
prompt: config.systemPrompt,
|
||||
configProvider,
|
||||
tools,
|
||||
maxIterations: config.maxIterations,
|
||||
parentAgentId: context.agentId,
|
||||
abortSignal: context.signal,
|
||||
onEvent: options.onSubAgentEvent,
|
||||
hookErrorMode: options.hookErrorMode,
|
||||
toolPolicies: options.toolPolicies,
|
||||
requestToolApproval: options.requestToolApproval,
|
||||
});
|
||||
const subAgentId = subAgent.getAgentId();
|
||||
const conversationId = subAgent.getConversationId();
|
||||
const parentAgentId = context.agentId;
|
||||
const spawnInput = {
|
||||
systemPrompt: config.systemPrompt,
|
||||
task: input.prompt,
|
||||
};
|
||||
|
||||
if (options.onSubAgentStart) {
|
||||
try {
|
||||
await options.onSubAgentStart({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result: AgentResult = await subAgent.run(input.prompt);
|
||||
const output: SpawnAgentOutput = {
|
||||
text: result.text,
|
||||
iterations: result.iterations,
|
||||
finishReason: result.finishReason,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
},
|
||||
};
|
||||
if (options.onSubAgentEnd) {
|
||||
try {
|
||||
await options.onSubAgentEnd({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
result: output,
|
||||
agentResult: result,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
return output;
|
||||
} catch (error) {
|
||||
if (options.onSubAgentEnd) {
|
||||
try {
|
||||
await options.onSubAgentEnd({
|
||||
subAgentId,
|
||||
conversationId,
|
||||
parentAgentId,
|
||||
input: spawnInput,
|
||||
error:
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort observer callback.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
timeoutMs: 300000,
|
||||
retryable: false,
|
||||
});
|
||||
return tool as unknown as AgentTool;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
export {
|
||||
type ConfiguredAgentConfig,
|
||||
type ConfiguredAgentLoadResult,
|
||||
type ConfiguredAgentReadError,
|
||||
loadConfiguredAgentConfigs,
|
||||
parseConfiguredAgentConfig,
|
||||
} from "./configured-agent-config";
|
||||
export {
|
||||
buildConfiguredAgentToolDescriptors,
|
||||
buildConfiguredAgentToolName,
|
||||
type ConfiguredAgentInput,
|
||||
type ConfiguredAgentToolConfig,
|
||||
type ConfiguredAgentToolDescriptor,
|
||||
createConfiguredAgentTools,
|
||||
} from "./configured-agent-tool";
|
||||
export {
|
||||
buildTeamProgressSummary,
|
||||
toTeamProgressLifecycleEvent,
|
||||
} from "./projections";
|
||||
export * from "./runtime";
|
||||
export type {
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "./spawn-agent-tool";
|
||||
|
||||
@@ -46,6 +46,35 @@ describe("resolveHubUrl", () => {
|
||||
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
it("uses the shared discovery owner in development builds", async () => {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-connect-test-data";
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
const readHubDiscovery = vi
|
||||
.spyOn(await import("../discovery"), "readHubDiscovery")
|
||||
.mockResolvedValue({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v1",
|
||||
authToken: "test-token",
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
});
|
||||
|
||||
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25466/hub");
|
||||
|
||||
const discoveryPath = readHubDiscovery.mock.calls[0]?.[0].replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
expect(discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(discoveryPath).not.toBe(
|
||||
"/tmp/cline-connect-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default endpoint when no discovery file exists", async () => {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = "/tmp/missing-hub-discovery.json";
|
||||
vi.spyOn(
|
||||
|
||||
@@ -3,12 +3,16 @@ import type {
|
||||
HubReplyEnvelope,
|
||||
HubTransportFrame,
|
||||
} from "@cline/shared";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createHubServerUrl, readHubDiscovery } from "../discovery";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
export interface HubConnection {
|
||||
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
|
||||
@@ -68,13 +72,19 @@ function sameHubEndpoint(left: string, right: string): boolean {
|
||||
return leftUrl.toString() === rightUrl.toString();
|
||||
}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
|
||||
const queryToken = url.searchParams.get("authToken")?.trim();
|
||||
url.searchParams.delete("authToken");
|
||||
if (queryToken) {
|
||||
return queryToken;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url && sameHubEndpoint(url.toString(), discovery.url)) {
|
||||
return discovery.authToken;
|
||||
@@ -87,7 +97,7 @@ export async function resolveHubUrl(
|
||||
): Promise<string> {
|
||||
const endpoint = resolveHubEndpointOptions(overrides);
|
||||
if (!hasExplicitEndpoint(overrides)) {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (discovery?.url) {
|
||||
return discovery.url;
|
||||
|
||||
@@ -546,6 +546,10 @@ describe("NodeHubClient", () => {
|
||||
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-recovery.json",
|
||||
@@ -697,6 +701,10 @@ describe("NodeHubClient", () => {
|
||||
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery-explicit.json",
|
||||
@@ -764,6 +772,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
it("does not clear discovery on transient probe failure", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -798,9 +810,13 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery on build mismatch", async () => {
|
||||
it("keeps discovery on build mismatch when protocol is compatible", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -840,15 +856,19 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
|
||||
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/hub-discovery.json",
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
|
||||
"ws://127.0.0.1:59999/hub",
|
||||
);
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery when a hub omits build metadata", async () => {
|
||||
it("keeps discovery when a hub omits build metadata but has compatible protocol", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -886,6 +906,57 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
|
||||
"ws://127.0.0.1:59999/hub",
|
||||
);
|
||||
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears discovery on protocol mismatch", async () => {
|
||||
const clearHubDiscoveryMock = vi.fn();
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
readHubDiscovery: vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v0",
|
||||
buildId: "old-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
clearHubDiscovery: vi.fn(async (...args: unknown[]) => {
|
||||
clearHubDiscoveryMock(...args);
|
||||
}),
|
||||
probeHubServer: vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v0",
|
||||
buildId: "old-build",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const { resolveCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
|
||||
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/hub-discovery.json",
|
||||
@@ -914,6 +985,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
@@ -950,6 +1025,73 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
).toBeLessThan(readHubDiscoveryMock.mock.invocationCallOrder[1]);
|
||||
});
|
||||
|
||||
it("waits on shared discovery after spawning in development builds", async () => {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
const spawnDetachedHubServerWithRetryMock = vi.fn(async () => undefined);
|
||||
const record = {
|
||||
hubId: "hub-test",
|
||||
protocolVersion: "v1",
|
||||
buildId: "test-build",
|
||||
authToken: "token",
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const readHubDiscoveryMock = vi.fn(async (path: string) =>
|
||||
path === "/tmp/shared-hub-discovery.json" ? record : undefined,
|
||||
);
|
||||
vi.doMock("../daemon", () => ({
|
||||
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/production-hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/shared-hub-discovery.json",
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../discovery", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../discovery")>("../discovery");
|
||||
return {
|
||||
...actual,
|
||||
readHubDiscovery: readHubDiscoveryMock,
|
||||
probeHubServer: vi.fn(async () => record),
|
||||
clearHubDiscovery: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const { ensureCompatibleLocalHubUrl } = await import(".");
|
||||
|
||||
await expect(
|
||||
ensureCompatibleLocalHubUrl({
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
}),
|
||||
).resolves.toBe("ws://127.0.0.1:25466/hub");
|
||||
expect(readHubDiscoveryMock).toHaveBeenCalledWith(
|
||||
"/tmp/shared-hub-discovery.json",
|
||||
);
|
||||
expect(readHubDiscoveryMock).not.toHaveBeenCalledWith(
|
||||
"/tmp/production-hub-discovery.json",
|
||||
);
|
||||
} finally {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not restart explicit local endpoints after startup timeout", async () => {
|
||||
const readHubDiscoveryMock = vi.fn(async () => ({
|
||||
hubId: "hub-test",
|
||||
@@ -963,6 +1105,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
vi.doMock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
}),
|
||||
resolveSharedHubOwnerContext: () => ({
|
||||
ownerId: "hub-test",
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type HubEventEnvelope,
|
||||
type HubReplyEnvelope,
|
||||
type HubTransportFrame,
|
||||
isHubProtocolCompatible,
|
||||
resolveClineBuildEnv,
|
||||
resolveHubCommandTimeoutMs,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
@@ -17,9 +19,11 @@ import {
|
||||
type HubOwnerContext,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubBuildId,
|
||||
} from "../discovery";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
type PendingReply = {
|
||||
resolve: (reply: HubReplyEnvelope) => void;
|
||||
@@ -31,6 +35,12 @@ type SubscriptionEntry = {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
function resolveDefaultHubOwnerContext(): HubOwnerContext {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
type WebSocketLike = {
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
@@ -821,7 +831,7 @@ type HubProbeResult =
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
status: "unreachable" | "build_mismatch";
|
||||
status: "unreachable" | "protocol_mismatch";
|
||||
url: string;
|
||||
};
|
||||
|
||||
@@ -835,18 +845,18 @@ async function probeCompatibleHubUrl(
|
||||
},
|
||||
): Promise<HubProbeResult> {
|
||||
const normalized = normalizeHubWebSocketUrl(url);
|
||||
const record = await probeHubServer(normalized);
|
||||
const record = await probeHubServer(normalized, {
|
||||
authToken: options?.authToken,
|
||||
});
|
||||
if (!record) {
|
||||
return {
|
||||
status: "unreachable",
|
||||
url: normalized,
|
||||
};
|
||||
}
|
||||
const buildId = resolveHubBuildId();
|
||||
const recordBuildId = record.buildId?.trim();
|
||||
if (!recordBuildId || recordBuildId !== buildId) {
|
||||
if (!isHubProtocolCompatible(record).compatible) {
|
||||
return {
|
||||
status: "build_mismatch",
|
||||
status: "protocol_mismatch",
|
||||
url: normalized,
|
||||
};
|
||||
}
|
||||
@@ -973,16 +983,18 @@ export async function resolveCompatibleLocalHubUrl(
|
||||
return compatible.status === "compatible" ? compatible.url : undefined;
|
||||
}
|
||||
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const record = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!record?.url) {
|
||||
return undefined;
|
||||
}
|
||||
const compatible = await probeCompatibleHubUrl(record.url);
|
||||
const compatible = await probeCompatibleHubUrl(record.url, {
|
||||
authToken: record.authToken,
|
||||
});
|
||||
if (compatible.status === "compatible") {
|
||||
return rememberRecoverableLocalHubUrl(compatible.url, record.authToken);
|
||||
}
|
||||
if (compatible.status === "build_mismatch") {
|
||||
if (compatible.status === "protocol_mismatch") {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
return undefined;
|
||||
@@ -1004,7 +1016,7 @@ export async function ensureCompatibleLocalHubUrl(
|
||||
if (options.endpoint?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
await spawnDetachedHubServerWithRetry(options.workspaceRoot ?? process.cwd());
|
||||
return await waitForCompatibleHubUrl(owner);
|
||||
}
|
||||
@@ -1032,8 +1044,9 @@ export async function requestHubShutdown(
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function stopLocalHubServerGracefully(): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
export async function stopLocalHubServerGracefully(
|
||||
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
|
||||
): Promise<boolean> {
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url) {
|
||||
return false;
|
||||
@@ -1060,7 +1073,7 @@ export async function restartLocalHubIfIdleAfterStartupTimeout(options: {
|
||||
if (!isRecoverableLocalHubUrl(options.url)) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (!discovery?.url || !sameNormalizedHubUrl(discovery.url, options.url)) {
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
mockInitVcr,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStartHubWebSocketServer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateLocalHubScheduleRuntimeHandlers: vi.fn(() => ({
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
stopSession: vi.fn(),
|
||||
abortSession: vi.fn(),
|
||||
})),
|
||||
mockInitVcr: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(
|
||||
(options: { host?: string; port?: number; pathname?: string }) => ({
|
||||
host: options.host ?? "127.0.0.1",
|
||||
port: options.port ?? 25463,
|
||||
pathname: options.pathname ?? "/hub",
|
||||
}),
|
||||
),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
})),
|
||||
mockStartHubWebSocketServer: vi.fn(async () => ({
|
||||
close: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/shared", () => ({
|
||||
initVcr: mockInitVcr,
|
||||
resolveClineBuildEnv: () => "production",
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/runtime-handlers", () => ({
|
||||
createLocalHubScheduleRuntimeHandlers:
|
||||
mockCreateLocalHubScheduleRuntimeHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/defaults", () => ({
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
vi.mock("../server", () => ({
|
||||
startHubWebSocketServer: mockStartHubWebSocketServer,
|
||||
}));
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
describe("hub daemon entry", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
process.chdir(originalCwd);
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
mockCreateLocalHubScheduleRuntimeHandlers.mockClear();
|
||||
mockInitVcr.mockClear();
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the daemon with cron options for the daemon workspace root", async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), "cline-hub-entry-test-"));
|
||||
tempDirs.push(cwd);
|
||||
process.argv = [
|
||||
"node",
|
||||
"entry.js",
|
||||
"--cwd",
|
||||
cwd,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"30000",
|
||||
"--pathname",
|
||||
"/hub",
|
||||
];
|
||||
vi.spyOn(process, "on").mockImplementation(() => process);
|
||||
|
||||
await import("./entry");
|
||||
await vi.waitFor(() => {
|
||||
expect(mockStartHubWebSocketServer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockStartHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "127.0.0.1",
|
||||
port: 30000,
|
||||
pathname: "/hub",
|
||||
owner: expect.objectContaining({ ownerId: "production" }),
|
||||
cronOptions: { workspaceRoot: cwd },
|
||||
}),
|
||||
);
|
||||
expect(mockCreateLocalHubScheduleRuntimeHandlers).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AgentRuntimeAbortError } from "@cline/agents";
|
||||
import { initVcr } from "@cline/shared";
|
||||
import { initVcr, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import { startHubWebSocketServer } from "../server";
|
||||
|
||||
initVcr(process.env.CLINE_VCR);
|
||||
@@ -62,7 +65,10 @@ async function main(): Promise<void> {
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
pathname: endpoint.pathname,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
owner:
|
||||
resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext(),
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
cronOptions: { workspaceRoot: options.cwd },
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
openSync,
|
||||
rememberRecoverableLocalHubUrl,
|
||||
verifyHubConnection,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
createHubServerUrl,
|
||||
clearHubDiscovery,
|
||||
@@ -24,6 +25,9 @@ const {
|
||||
openSync: vi.fn(() => 17),
|
||||
rememberRecoverableLocalHubUrl: vi.fn((url: string) => url),
|
||||
verifyHubConnection: vi.fn(),
|
||||
resolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
resolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
discoveryPath: "/tmp/hub-discovery.json",
|
||||
})),
|
||||
@@ -57,6 +61,9 @@ vi.mock("@cline/shared", () => ({
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
CLINE_HUB_PORT: 25463,
|
||||
CLINE_HUB_DEV_PORT: 25466,
|
||||
isHubProtocolCompatible: (record: { protocolVersion?: string }) => ({
|
||||
compatible: record.protocolVersion === "v1",
|
||||
}),
|
||||
isHubDaemonProcess: (env: NodeJS.ProcessEnv = process.env) =>
|
||||
env[CLINE_RUN_AS_HUB_DAEMON_ENV] === "1",
|
||||
resolveClineBuildEnv: () => "production",
|
||||
@@ -70,6 +77,7 @@ vi.mock("../client", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
@@ -88,6 +96,21 @@ describe("ensureDetachedHubServer", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env[CLINE_RUN_AS_HUB_DAEMON_ENV];
|
||||
spawn.mockReset();
|
||||
spawn.mockImplementation(() => ({ unref: vi.fn() }));
|
||||
closeSync.mockReset();
|
||||
mkdirSync.mockReset();
|
||||
openSync.mockReset();
|
||||
openSync.mockImplementation(() => 17);
|
||||
rememberRecoverableLocalHubUrl.mockReset();
|
||||
rememberRecoverableLocalHubUrl.mockImplementation((url: string) => url);
|
||||
verifyHubConnection.mockReset();
|
||||
clearHubDiscovery.mockReset();
|
||||
clearHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockReset();
|
||||
requestHubShutdown.mockReset();
|
||||
requestHubShutdown.mockResolvedValue(true);
|
||||
readHubDiscovery.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
@@ -101,20 +124,16 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets the daemon bind port 0 when the configured endpoint is occupied", async () => {
|
||||
it("does not use port 0 for default production startup", async () => {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
@@ -129,12 +148,13 @@ describe("ensureDetachedHubServer", () => {
|
||||
| undefined;
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(spawnArgs).toContain("--port");
|
||||
expect(spawnArgs).toContain("0");
|
||||
expect(spawnArgs).toContain("25463");
|
||||
expect(spawnArgs).not.toContain("0");
|
||||
expect(spawnOptions?.env?.[CLINE_RUN_AS_HUB_DAEMON_ENV]).toBe("1");
|
||||
});
|
||||
|
||||
@@ -153,11 +173,12 @@ describe("ensureDetachedHubServer", () => {
|
||||
})
|
||||
.mockImplementationOnce(() => ({ unref: vi.fn() }));
|
||||
readHubDiscovery.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer.mockResolvedValueOnce(undefined).mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
@@ -168,7 +189,7 @@ describe("ensureDetachedHubServer", () => {
|
||||
const result = await pending;
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
@@ -247,7 +268,42 @@ describe("ensureDetachedHubServer", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub from a different build", async () => {
|
||||
it("prewarms on a fallback port when an empty-token hub cannot be retired", async () => {
|
||||
vi.useFakeTimers();
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "",
|
||||
pid: 12345,
|
||||
});
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
});
|
||||
|
||||
const { prewarmDetachedHubServer } = await import(".");
|
||||
prewarmDetachedHubServer("/workspace", { allowPortFallback: true });
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const spawnArgs = ((spawn as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls[0]?.[1] ?? []) as string[];
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(spawnArgs).toContain("--port");
|
||||
expect(spawnArgs).toContain("0");
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a protocol-compatible healthy hub from a different build", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
@@ -255,54 +311,215 @@ describe("ensureDetachedHubServer", () => {
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
buildId: "current-build",
|
||||
});
|
||||
.mockResolvedValueOnce(undefined);
|
||||
probeHubServer.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "old-build",
|
||||
pid: 12345,
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
authToken: "new-token",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"old-token",
|
||||
);
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
|
||||
expect(clearHubDiscovery.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
probeHubServer.mock.invocationCallOrder[2],
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
expect(requestHubShutdown).not.toHaveBeenCalled();
|
||||
expect(clearHubDiscovery).not.toHaveBeenCalled();
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(verifyHubConnection).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub without build metadata", async () => {
|
||||
it("retires an existing hub with an empty discovery auth token before starting a replacement", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith("/tmp/hub-discovery.json");
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a targeted error when an incompatible hub cannot be retired", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v2",
|
||||
buildId: "future-build",
|
||||
});
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const pending = expect(
|
||||
ensureDetachedHubServer("/workspace"),
|
||||
).rejects.toThrow(
|
||||
"An incompatible Cline Hub is already running at ws://127.0.0.1:25463/hub and could not be retired automatically.",
|
||||
);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await pending;
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retires a legacy shared production hub before resolving the production hub", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
resolveSharedHubOwnerContext.mockReturnValueOnce({
|
||||
discoveryPath: "/tmp/legacy-hub-discovery.json",
|
||||
});
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:39121/hub",
|
||||
authToken: "legacy-token",
|
||||
pid: 222,
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:39121/hub",
|
||||
"legacy-token",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(222, "SIGTERM");
|
||||
expect(clearHubDiscovery).toHaveBeenCalledWith(
|
||||
"/tmp/legacy-hub-discovery.json",
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when a compatible expected hub has no discovery record", async () => {
|
||||
readHubDiscovery.mockResolvedValue(undefined);
|
||||
probeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
await expect(ensureDetachedHubServer("/workspace")).rejects.toThrow(
|
||||
"A compatible Cline Hub is already running at ws://127.0.0.1:25463/hub, but its discovery record is missing or unreadable.",
|
||||
);
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses matching discovery pid and token when retiring an incompatible expected-url hub", async () => {
|
||||
const kill = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((_pid, signal) => {
|
||||
if (signal === 0) {
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
readHubDiscovery
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "old-token",
|
||||
pid: 12345,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
probeHubServer
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v2",
|
||||
buildId: "future-build",
|
||||
})
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
|
||||
const { ensureDetachedHubServer } = await import(".");
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
"old-token",
|
||||
);
|
||||
expect(kill).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
} finally {
|
||||
kill.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse a healthy hub without protocol metadata", async () => {
|
||||
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
try {
|
||||
readHubDiscovery
|
||||
@@ -311,7 +528,8 @@ describe("ensureDetachedHubServer", () => {
|
||||
authToken: "old-token",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
authToken: "new-token",
|
||||
});
|
||||
@@ -327,7 +545,13 @@ describe("ensureDetachedHubServer", () => {
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
})
|
||||
.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
protocolVersion: "v1",
|
||||
buildId: "current-build",
|
||||
});
|
||||
verifyHubConnection.mockResolvedValueOnce(true);
|
||||
@@ -336,7 +560,7 @@ describe("ensureDetachedHubServer", () => {
|
||||
const result = await ensureDetachedHubServer("/workspace");
|
||||
|
||||
expect(result).toEqual({
|
||||
url: "ws://127.0.0.1:5555/hub",
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "new-token",
|
||||
});
|
||||
expect(requestHubShutdown).toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
CLINE_RUN_AS_HUB_DAEMON_ENV,
|
||||
isHubDaemonProcess,
|
||||
isHubProtocolCompatible,
|
||||
resolveClineBuildEnv,
|
||||
withResolvedClineBuildEnv,
|
||||
} from "@cline/shared";
|
||||
import {
|
||||
@@ -15,17 +17,20 @@ import {
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
createHubServerUrl,
|
||||
type HubServerDiscoveryRecord,
|
||||
type HubOwnerContext,
|
||||
type HubServerProbeRecord,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveHubBuildId,
|
||||
} from "../discovery";
|
||||
import {
|
||||
type HubEndpointOverrides,
|
||||
resolveHubEndpointOptions,
|
||||
} from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
|
||||
const HUB_STARTUP_TIMEOUT_MS = 8_000;
|
||||
const HUB_STARTUP_POLL_MS = 200;
|
||||
@@ -54,16 +59,37 @@ function openDetachedHubLogFile(): { fd: number; logPath: string } | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function isCompatibleHubRecord(record: HubServerDiscoveryRecord): boolean {
|
||||
const recordBuildId = record.buildId?.trim();
|
||||
return !!recordBuildId && recordBuildId === resolveHubBuildId();
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function isCompatibleHubRecord(record: HubServerProbeRecord): boolean {
|
||||
return isHubProtocolCompatible(record).compatible;
|
||||
}
|
||||
|
||||
function withMatchingDiscoveryRetirementMetadata(
|
||||
probe: HubServerProbeRecord,
|
||||
discovered: { url?: string; authToken?: string; pid?: number } | undefined,
|
||||
expectedUrl: string,
|
||||
): HubServerProbeRecord {
|
||||
if (!discovered || discovered.url !== expectedUrl) {
|
||||
return probe;
|
||||
}
|
||||
return {
|
||||
...probe,
|
||||
authToken: probe.authToken ?? discovered.authToken,
|
||||
pid: probe.pid ?? discovered.pid,
|
||||
};
|
||||
}
|
||||
|
||||
async function safeProbeHubServer(
|
||||
url: string,
|
||||
): Promise<HubServerDiscoveryRecord | undefined> {
|
||||
authToken?: string,
|
||||
): Promise<HubServerProbeRecord | undefined> {
|
||||
try {
|
||||
return await probeHubServer(url);
|
||||
return await probeHubServer(url, { authToken });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -84,13 +110,10 @@ async function waitForHubToRetire(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerDiscoveryRecord,
|
||||
async function retireDiscoveredHub(
|
||||
record: { url: string; authToken?: string; pid?: number },
|
||||
discoveryPath: string,
|
||||
): Promise<void> {
|
||||
if (isCompatibleHubRecord(record)) {
|
||||
return;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
await requestHubShutdown(record.url, record.authToken).catch(() => false);
|
||||
if (record.pid) {
|
||||
try {
|
||||
@@ -99,8 +122,43 @@ async function retireIncompatibleHub(
|
||||
// Best-effort cleanup only. A compatible hub may still start on a fallback port.
|
||||
}
|
||||
}
|
||||
await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
|
||||
const retired = await waitForHubToRetire(record.url, HUB_RETIRE_TIMEOUT_MS);
|
||||
await clearHubDiscovery(discoveryPath).catch(() => undefined);
|
||||
return retired;
|
||||
}
|
||||
|
||||
async function retireIncompatibleHub(
|
||||
record: HubServerProbeRecord,
|
||||
discoveryPath: string,
|
||||
): Promise<boolean> {
|
||||
if (isCompatibleHubRecord(record)) {
|
||||
return true;
|
||||
}
|
||||
return retireDiscoveredHub(record, discoveryPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-singleton production builds tracked the local hub under the shared
|
||||
* owner discovery path and spawned daemons on random fallback ports. Those
|
||||
* daemons are invisible to the production owner context, so nothing would
|
||||
* ever reuse or stop them. Retire the recorded legacy hub (its record carries
|
||||
* the auth token and pid needed for a graceful stop) and clear the legacy
|
||||
* record so upgrades do not leave orphaned daemons running stale code.
|
||||
*/
|
||||
async function retireLegacySharedHub(owner: HubOwnerContext): Promise<void> {
|
||||
if (resolveClineBuildEnv() !== "production") {
|
||||
return;
|
||||
}
|
||||
const legacy = resolveSharedHubOwnerContext();
|
||||
if (legacy.discoveryPath === owner.discoveryPath) {
|
||||
return;
|
||||
}
|
||||
const record = await readHubDiscovery(legacy.discoveryPath);
|
||||
if (record?.url) {
|
||||
await retireDiscoveredHub(record, legacy.discoveryPath);
|
||||
} else {
|
||||
await clearHubDiscovery(legacy.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDaemonEntryPath(): string {
|
||||
@@ -200,48 +258,75 @@ export async function spawnDetachedHubServerWithRetry(
|
||||
|
||||
export function prewarmDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
endpoint: HubEndpointOverrides & { allowPortFallback?: boolean } = {},
|
||||
): void {
|
||||
if (isHubDaemonProcess()) {
|
||||
return;
|
||||
}
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const hasExplicitPort =
|
||||
endpoint.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const resolvedEndpoint = resolveHubEndpointOptions(endpoint);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
resolvedEndpoint.host,
|
||||
resolvedEndpoint.port,
|
||||
resolvedEndpoint.pathname,
|
||||
);
|
||||
void readHubDiscovery(owner.discoveryPath)
|
||||
const shouldUseFallbackPort =
|
||||
endpoint.allowPortFallback === true && resolvedEndpoint.port !== 0;
|
||||
void retireLegacySharedHub(owner)
|
||||
.catch(() => undefined)
|
||||
.then(() => readHubDiscovery(owner.discoveryPath))
|
||||
.then(async (discovered) => {
|
||||
let retiredUnusableDiscovery = false;
|
||||
if (discovered?.url) {
|
||||
const healthy = await safeProbeHubServer(discovered.url);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
if (!discovered.authToken) {
|
||||
retiredUnusableDiscovery = true;
|
||||
const retired = await retireDiscoveredHub(
|
||||
discovered,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (!retired && !shouldUseFallbackPort) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
const healthy = await safeProbeHubServer(
|
||||
discovered.url,
|
||||
discovered.authToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = await safeProbeHubServer(expectedUrl);
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
if (isCompatibleHubRecord(expected)) {
|
||||
if (!shouldUseFallbackPort || !retiredUnusableDiscovery) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
{ ...expected, authToken: undefined },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (!retiredExpected && !shouldUseFallbackPort) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const shouldUseFallbackPort =
|
||||
!hasExplicitPort && resolvedEndpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...resolvedEndpoint, port: 0 }
|
||||
: resolvedEndpoint;
|
||||
@@ -259,17 +344,16 @@ export interface DetachedHubResolution {
|
||||
|
||||
export async function ensureDetachedHubServer(
|
||||
workspaceRoot: string,
|
||||
endpointOverrides: HubEndpointOverrides = {},
|
||||
endpointOverrides: HubEndpointOverrides & {
|
||||
allowPortFallback?: boolean;
|
||||
} = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveDefaultHubOwnerContext();
|
||||
const hasExplicitEndpoint =
|
||||
endpointOverrides.host !== undefined ||
|
||||
endpointOverrides.port !== undefined ||
|
||||
endpointOverrides.pathname !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const hasExplicitPort =
|
||||
endpointOverrides.port !== undefined ||
|
||||
!!process.env.CLINE_HUB_PORT?.trim();
|
||||
const endpoint = resolveHubEndpointOptions(endpointOverrides);
|
||||
const expectedUrl = createHubServerUrl(
|
||||
endpoint.host,
|
||||
@@ -284,35 +368,72 @@ export async function ensureDetachedHubServer(
|
||||
}
|
||||
return result;
|
||||
};
|
||||
await retireLegacySharedHub(owner).catch(() => undefined);
|
||||
const discovered = await readHubDiscovery(owner.discoveryPath);
|
||||
let retiredUnusableDiscovery = false;
|
||||
if (discovered?.url) {
|
||||
const healthy = await safeProbeHubServer(discovered.url);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discovered.authToken,
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discovered.authToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
const discoveredAuthToken = discovered.authToken;
|
||||
if (!discoveredAuthToken) {
|
||||
retiredUnusableDiscovery = true;
|
||||
await retireDiscoveredHub(discovered, owner.discoveryPath);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
const healthy = await safeProbeHubServer(
|
||||
discovered.url,
|
||||
discoveredAuthToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discoveredAuthToken,
|
||||
}))
|
||||
) {
|
||||
return rememberIfManaged({
|
||||
url: healthy.url,
|
||||
authToken: discoveredAuthToken,
|
||||
});
|
||||
}
|
||||
if (healthy?.url) {
|
||||
await retireIncompatibleHub(
|
||||
{ ...healthy, authToken: discoveredAuthToken },
|
||||
owner.discoveryPath,
|
||||
);
|
||||
} else {
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
const expected = await safeProbeHubServer(expectedUrl);
|
||||
if (expected?.url) {
|
||||
await retireIncompatibleHub(expected, owner.discoveryPath);
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
expected,
|
||||
discovered,
|
||||
expectedUrl,
|
||||
);
|
||||
if (isCompatibleHubRecord(expected)) {
|
||||
const upgradeHint = retiredUnusableDiscovery
|
||||
? " This can happen immediately after upgrading from a build that wrote an empty hub auth token; run 'cline doctor fix' to stop the old daemon and repair local hub discovery."
|
||||
: "";
|
||||
throw new Error(
|
||||
`A compatible Cline Hub is already running at ${expectedUrl}, but its discovery record is missing or unreadable. Run 'cline doctor fix' to repair local hub discovery.${upgradeHint}`,
|
||||
);
|
||||
}
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
`An incompatible Cline Hub is already running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const shouldUseFallbackPort = !hasExplicitPort && endpoint.port !== 0;
|
||||
const shouldUseFallbackPort =
|
||||
endpointOverrides.allowPortFallback === true && endpoint.port !== 0;
|
||||
const spawnEndpoint = shouldUseFallbackPort
|
||||
? { ...endpoint, port: 0 }
|
||||
: endpoint;
|
||||
@@ -320,8 +441,11 @@ export async function ensureDetachedHubServer(
|
||||
const deadline = Date.now() + HUB_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const nextDiscovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (nextDiscovery?.url) {
|
||||
const healthy = await safeProbeHubServer(nextDiscovery.url);
|
||||
if (nextDiscovery?.url && nextDiscovery.authToken) {
|
||||
const healthy = await safeProbeHubServer(
|
||||
nextDiscovery.url,
|
||||
nextDiscovery.authToken,
|
||||
);
|
||||
if (
|
||||
healthy?.url &&
|
||||
isCompatibleHubRecord(healthy) &&
|
||||
@@ -337,7 +461,24 @@ export async function ensureDetachedHubServer(
|
||||
}
|
||||
const nextExpected = await safeProbeHubServer(expectedUrl);
|
||||
if (nextExpected?.url && !isCompatibleHubRecord(nextExpected)) {
|
||||
await retireIncompatibleHub(nextExpected, owner.discoveryPath);
|
||||
const expectedForRetirement = withMatchingDiscoveryRetirementMetadata(
|
||||
nextExpected,
|
||||
nextDiscovery,
|
||||
expectedUrl,
|
||||
);
|
||||
const retiredExpected = await retireIncompatibleHub(
|
||||
expectedForRetirement,
|
||||
owner.discoveryPath,
|
||||
);
|
||||
if (
|
||||
!retiredExpected &&
|
||||
endpointOverrides.allowPortFallback !== true &&
|
||||
endpoint.port !== 0
|
||||
) {
|
||||
throw new Error(
|
||||
`An incompatible Cline Hub is still running at ${expectedUrl} and could not be retired automatically. Run 'cline doctor fix' to stop stale hub daemons before starting a new hub.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, HUB_STARTUP_POLL_MS));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EnsureHubServerOptions } from "./start-shared-server";
|
||||
|
||||
const {
|
||||
mockEnsureHubWebSocketServer,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockResolveClineBuildEnv,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStartHubWebSocketServer,
|
||||
} = vi.hoisted(() => ({
|
||||
mockEnsureHubWebSocketServer: vi.fn(async () => ({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
action: "started",
|
||||
})),
|
||||
mockResolveHubEndpointOptions: vi.fn(
|
||||
(options: { host?: string; port?: number; pathname?: string }) => ({
|
||||
host: options.host ?? "127.0.0.1",
|
||||
port: options.port ?? 25463,
|
||||
pathname: options.pathname ?? "/hub",
|
||||
}),
|
||||
),
|
||||
mockResolveClineBuildEnv: vi.fn(() => "production"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "shared",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
})),
|
||||
mockStartHubWebSocketServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/shared", () => ({
|
||||
resolveClineBuildEnv: mockResolveClineBuildEnv,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/defaults", () => ({
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
}));
|
||||
|
||||
vi.mock("../discovery/workspace", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
}));
|
||||
|
||||
vi.mock("../server", () => ({
|
||||
ensureHubWebSocketServer: mockEnsureHubWebSocketServer,
|
||||
startHubWebSocketServer: mockStartHubWebSocketServer,
|
||||
}));
|
||||
|
||||
const originalHubPort = process.env.CLINE_HUB_PORT;
|
||||
const runtimeHandlers =
|
||||
{} as unknown as EnsureHubServerOptions["runtimeHandlers"];
|
||||
|
||||
describe("ensureHubServer", () => {
|
||||
afterEach(() => {
|
||||
mockEnsureHubWebSocketServer.mockClear();
|
||||
mockResolveHubEndpointOptions.mockClear();
|
||||
mockResolveClineBuildEnv.mockClear();
|
||||
mockResolveClineBuildEnv.mockReturnValue("production");
|
||||
mockResolveProductionHubOwnerContext.mockClear();
|
||||
mockResolveSharedHubOwnerContext.mockClear();
|
||||
mockStartHubWebSocketServer.mockClear();
|
||||
if (originalHubPort === undefined) {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
} else {
|
||||
process.env.CLINE_HUB_PORT = originalHubPort;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not allow port fallback by default in production", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 25463,
|
||||
allowPortFallback: false,
|
||||
owner: expect.objectContaining({
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows port fallback by default in development when no port is explicit", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
mockResolveClineBuildEnv.mockReturnValue("development");
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 25463,
|
||||
allowPortFallback: true,
|
||||
owner: expect.objectContaining({
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/shared.json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not default port fallback when a port option is explicit", async () => {
|
||||
delete process.env.CLINE_HUB_PORT;
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ port: 30000, runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 30000,
|
||||
allowPortFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not default port fallback when CLINE_HUB_PORT is explicit", async () => {
|
||||
process.env.CLINE_HUB_PORT = "30001";
|
||||
const { ensureHubServer } = await import("./start-shared-server");
|
||||
|
||||
await ensureHubServer({ runtimeHandlers });
|
||||
|
||||
expect(mockEnsureHubWebSocketServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowPortFallback: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { resolveHubEndpointOptions } from "../discovery/defaults";
|
||||
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
|
||||
import {
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
} from "../discovery/workspace";
|
||||
import {
|
||||
type EnsuredHubWebSocketServerResult,
|
||||
type EnsureHubWebSocketServerOptions,
|
||||
@@ -18,9 +22,19 @@ export interface StartHubServerOptions
|
||||
export interface EnsureHubServerOptions
|
||||
extends Omit<EnsureHubWebSocketServerOptions, "owner"> {}
|
||||
|
||||
function resolveDefaultHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
function shouldAllowDefaultPortFallback(hasExplicitPort: boolean): boolean {
|
||||
return resolveClineBuildEnv() !== "production" && !hasExplicitPort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a hub WebSocket server bound to the process-local shared owner
|
||||
* context. Callers that need a custom owner should invoke
|
||||
* Start a hub WebSocket server bound to the default owner context for the
|
||||
* current build environment. Callers that need a custom owner should invoke
|
||||
* {@link startHubWebSocketServer} directly.
|
||||
*/
|
||||
export async function startHubServer(
|
||||
@@ -34,13 +48,13 @@ export async function startHubServer(
|
||||
return await startHubWebSocketServer({
|
||||
...options,
|
||||
...endpoint,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
owner: resolveDefaultHubOwnerContext(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a hub WebSocket server is running in the process-local shared owner
|
||||
* context, reusing a compatible in-process instance when available.
|
||||
* Ensure a hub WebSocket server is running in the default owner context for the
|
||||
* current build environment, reusing a compatible in-process instance when available.
|
||||
*/
|
||||
export async function ensureHubServer(
|
||||
options: EnsureHubServerOptions,
|
||||
@@ -55,7 +69,9 @@ export async function ensureHubServer(
|
||||
return await ensureHubWebSocketServer({
|
||||
...options,
|
||||
...endpoint,
|
||||
allowPortFallback: options.allowPortFallback ?? !hasExplicitPort,
|
||||
owner: resolveSharedHubOwnerContext(),
|
||||
allowPortFallback:
|
||||
options.allowPortFallback ??
|
||||
shouldAllowDefaultPortFallback(hasExplicitPort),
|
||||
owner: resolveDefaultHubOwnerContext(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveHubOwnerContext,
|
||||
writeHubDiscovery,
|
||||
@@ -88,4 +89,62 @@ describe("hub discovery", () => {
|
||||
await clearHubDiscovery(discoveryPath);
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects discovery records without an auth token", async () => {
|
||||
snapshot = captureEnv();
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
|
||||
const discoveryPath = resolveHubOwnerContext("missing-auth").discoveryPath;
|
||||
await mkdir(dirname(discoveryPath), { recursive: true });
|
||||
await writeFile(
|
||||
discoveryPath,
|
||||
`${JSON.stringify({
|
||||
hubId: "hub_123",
|
||||
protocolVersion: "v1",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(readHubDiscovery(discoveryPath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only public health fields for unauthenticated probes", async () => {
|
||||
const fetchMock = async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ok: true,
|
||||
protocolVersion: "v1",
|
||||
minClientProtocolVersion: "v1",
|
||||
maxClientProtocolVersion: "v1",
|
||||
coreVersion: "1.0.0",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
}),
|
||||
}) as Response;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
try {
|
||||
const record = await probeHubServer("ws://127.0.0.1:25463/hub");
|
||||
|
||||
expect(record).toMatchObject({
|
||||
protocolVersion: "v1",
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
});
|
||||
expect(record?.hubId).toBeUndefined();
|
||||
expect(record?.startedAt).toBeUndefined();
|
||||
expect(record?.updatedAt).toBeUndefined();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ const HUB_STARTUP_LOCK_POLL_MS = 100;
|
||||
export interface HubServerDiscoveryRecord {
|
||||
hubId: string;
|
||||
protocolVersion: string;
|
||||
minClientProtocolVersion?: string;
|
||||
maxClientProtocolVersion?: string;
|
||||
capabilities?: readonly string[];
|
||||
coreVersion?: string;
|
||||
buildId?: string;
|
||||
authToken: string;
|
||||
@@ -25,6 +28,23 @@ export interface HubServerDiscoveryRecord {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type HubServerProbeRecord = {
|
||||
protocolVersion: string;
|
||||
minClientProtocolVersion?: string;
|
||||
maxClientProtocolVersion?: string;
|
||||
capabilities?: readonly string[];
|
||||
coreVersion?: string;
|
||||
buildId?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
url: string;
|
||||
hubId?: string;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
startedAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export interface HubOwnerContext {
|
||||
ownerId: string;
|
||||
discoveryPath: string;
|
||||
@@ -135,6 +155,20 @@ export async function readHubDiscovery(
|
||||
return {
|
||||
hubId: parsed.hubId,
|
||||
protocolVersion: parsed.protocolVersion,
|
||||
minClientProtocolVersion:
|
||||
typeof parsed.minClientProtocolVersion === "string"
|
||||
? parsed.minClientProtocolVersion
|
||||
: undefined,
|
||||
maxClientProtocolVersion:
|
||||
typeof parsed.maxClientProtocolVersion === "string"
|
||||
? parsed.maxClientProtocolVersion
|
||||
: undefined,
|
||||
capabilities: Array.isArray(parsed.capabilities)
|
||||
? parsed.capabilities.filter(
|
||||
(capability): capability is string =>
|
||||
typeof capability === "string",
|
||||
)
|
||||
: undefined,
|
||||
coreVersion:
|
||||
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
|
||||
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
|
||||
@@ -225,13 +259,60 @@ export async function withHubStartupLock<T>(
|
||||
|
||||
export async function probeHubServer(
|
||||
url: string,
|
||||
): Promise<HubServerDiscoveryRecord | undefined> {
|
||||
options?: { authToken?: string },
|
||||
): Promise<HubServerProbeRecord | undefined> {
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(url));
|
||||
const response = await fetch(
|
||||
options?.authToken ? toHubStatusUrl(url) : toHubHealthUrl(url),
|
||||
{
|
||||
headers: options?.authToken
|
||||
? { authorization: `Bearer ${options.authToken}` }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return (await response.json()) as HubServerDiscoveryRecord;
|
||||
const parsed = (await response.json()) as Partial<HubServerProbeRecord>;
|
||||
if (
|
||||
typeof parsed.protocolVersion !== "string" ||
|
||||
typeof parsed.host !== "string" ||
|
||||
typeof parsed.port !== "number" ||
|
||||
typeof parsed.url !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
protocolVersion: parsed.protocolVersion,
|
||||
minClientProtocolVersion:
|
||||
typeof parsed.minClientProtocolVersion === "string"
|
||||
? parsed.minClientProtocolVersion
|
||||
: undefined,
|
||||
maxClientProtocolVersion:
|
||||
typeof parsed.maxClientProtocolVersion === "string"
|
||||
? parsed.maxClientProtocolVersion
|
||||
: undefined,
|
||||
capabilities: Array.isArray(parsed.capabilities)
|
||||
? parsed.capabilities.filter(
|
||||
(capability): capability is string =>
|
||||
typeof capability === "string",
|
||||
)
|
||||
: undefined,
|
||||
coreVersion:
|
||||
typeof parsed.coreVersion === "string" ? parsed.coreVersion : undefined,
|
||||
buildId: typeof parsed.buildId === "string" ? parsed.buildId : undefined,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
url: parsed.url,
|
||||
hubId: typeof parsed.hubId === "string" ? parsed.hubId : undefined,
|
||||
authToken:
|
||||
typeof parsed.authToken === "string" ? parsed.authToken : undefined,
|
||||
pid: typeof parsed.pid === "number" ? parsed.pid : undefined,
|
||||
startedAt:
|
||||
typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
|
||||
updatedAt:
|
||||
typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -253,6 +334,12 @@ export function toHubHealthUrl(wsUrl: string): string {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function toHubStatusUrl(wsUrl: string): string {
|
||||
const parsed = new URL(toHubHealthUrl(wsUrl));
|
||||
parsed.pathname = "/status";
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function isDiscoveryFilePresent(pathname: string): boolean {
|
||||
return existsSync(pathname);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { join } from "node:path";
|
||||
import { normalizeWorkspacePath } from "../../services/workspace/workspace-manifest";
|
||||
import { type HubOwnerContext, resolveHubOwnerContext } from ".";
|
||||
import {
|
||||
type HubOwnerContext,
|
||||
resolveClineDataDir,
|
||||
resolveHubOwnerContext,
|
||||
} from ".";
|
||||
|
||||
const DEFAULT_SHARED_HUB_OWNER_LABEL = "shared:cline";
|
||||
const HUB_DISCOVERY_ENV = "CLINE_HUB_DISCOVERY_PATH";
|
||||
const PRODUCTION_HUB_OWNER_ID = "hub-production";
|
||||
|
||||
export function resolveWorkspaceHubOwnerContext(
|
||||
workspaceRoot: string,
|
||||
@@ -17,3 +24,12 @@ export function resolveSharedHubOwnerContext(
|
||||
): HubOwnerContext {
|
||||
return resolveHubOwnerContext(label);
|
||||
}
|
||||
|
||||
export function resolveProductionHubOwnerContext(): HubOwnerContext {
|
||||
return {
|
||||
ownerId: PRODUCTION_HUB_OWNER_ID,
|
||||
discoveryPath:
|
||||
process.env[HUB_DISCOVERY_ENV]?.trim() ||
|
||||
join(resolveClineDataDir(), "locks", "hub", "production.json"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,6 +377,8 @@ function createUserInstructionServiceProxy(
|
||||
configuredSkills(snapshot, allowedSkillNames).some(
|
||||
(entry) => !entry.disabled,
|
||||
),
|
||||
createSkillsExecutor: (allowedSkillNames) =>
|
||||
createSnapshotSkillsExecutor(snapshot, allowedSkillNames),
|
||||
createExtension: (options): AgentExtension => ({
|
||||
name: "cline-hub-user-instructions",
|
||||
manifest: {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readBearerToken } from "./hub-websocket-server";
|
||||
|
||||
describe("readBearerToken", () => {
|
||||
it("reads a bearer token with case-insensitive scheme", () => {
|
||||
expect(readBearerToken("Bearer token")).toBe("token");
|
||||
expect(readBearerToken("bearer token")).toBe("token");
|
||||
});
|
||||
|
||||
it("reads a bearer token separated by tabs without regex backtracking", () => {
|
||||
expect(readBearerToken(`bearer\t\t${"token"}`)).toBe("token");
|
||||
expect(readBearerToken(`bearer${"\t".repeat(10_000)}token`)).toBe("token");
|
||||
});
|
||||
|
||||
it("rejects missing and malformed bearer tokens", () => {
|
||||
expect(readBearerToken(undefined)).toBeNull();
|
||||
expect(readBearerToken("Bearer")).toBeNull();
|
||||
expect(readBearerToken("BearerToken")).toBeNull();
|
||||
expect(readBearerToken("Basic token")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,13 @@ import { timingSafeEqual } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
import { URL } from "node:url";
|
||||
import {
|
||||
CURRENT_HUB_PROTOCOL_VERSION,
|
||||
HUB_CAPABILITIES,
|
||||
isHubProtocolCompatible,
|
||||
MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
} from "@cline/shared";
|
||||
import { WebSocketServer } from "ws";
|
||||
import corePackage from "../../../package.json";
|
||||
import { rememberRecoverableLocalHubUrl, verifyHubConnection } from "../client";
|
||||
@@ -204,10 +211,32 @@ function parseHeaderValue(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value.join(",") : (value ?? "");
|
||||
}
|
||||
|
||||
function readBearerToken(value: string | string[] | undefined): string | null {
|
||||
function isAuthHeaderWhitespace(code: number): boolean {
|
||||
return code === 0x20 || code === 0x09;
|
||||
}
|
||||
|
||||
export function readBearerToken(
|
||||
value: string | string[] | undefined,
|
||||
): string | null {
|
||||
const header = parseHeaderValue(value).trim();
|
||||
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||
return match?.[1]?.trim() || null;
|
||||
const bearerScheme = "bearer";
|
||||
if (
|
||||
header.length <= bearerScheme.length ||
|
||||
header.slice(0, bearerScheme.length).toLowerCase() !== bearerScheme ||
|
||||
!isAuthHeaderWhitespace(header.charCodeAt(bearerScheme.length))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tokenStart = bearerScheme.length + 1;
|
||||
while (
|
||||
tokenStart < header.length &&
|
||||
isAuthHeaderWhitespace(header.charCodeAt(tokenStart))
|
||||
) {
|
||||
tokenStart += 1;
|
||||
}
|
||||
|
||||
return header.slice(tokenStart).trim() || null;
|
||||
}
|
||||
|
||||
function readWebSocketAuthToken(
|
||||
@@ -244,7 +273,10 @@ export async function startHubWebSocketServer(
|
||||
const cleanup = new Set<() => void>();
|
||||
const startedAt = new Date().toISOString();
|
||||
const versionPayload = {
|
||||
protocolVersion: "v1",
|
||||
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
|
||||
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
capabilities: HUB_CAPABILITIES,
|
||||
coreVersion: corePackage.version,
|
||||
buildId,
|
||||
pid: process.pid,
|
||||
@@ -300,10 +332,36 @@ export async function startHubWebSocketServer(
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if ((req.url ?? "/") === "/health") {
|
||||
const body = JSON.stringify({
|
||||
ok: true,
|
||||
protocolVersion: versionPayload.protocolVersion,
|
||||
minClientProtocolVersion: versionPayload.minClientProtocolVersion,
|
||||
maxClientProtocolVersion: versionPayload.maxClientProtocolVersion,
|
||||
coreVersion: versionPayload.coreVersion,
|
||||
host,
|
||||
port,
|
||||
url,
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
if ((req.url ?? "/") === "/status") {
|
||||
if (
|
||||
!isValidHubAuthToken(
|
||||
readBearerToken(req.headers.authorization),
|
||||
authToken,
|
||||
)
|
||||
) {
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
hubId: transport.getHubId(),
|
||||
...versionPayload,
|
||||
authToken: "",
|
||||
authToken,
|
||||
host,
|
||||
port,
|
||||
url,
|
||||
@@ -449,7 +507,10 @@ export async function startHubWebSocketServer(
|
||||
|
||||
await writeHubDiscovery(owner.discoveryPath, {
|
||||
hubId: transport.getHubId(),
|
||||
protocolVersion: "v1",
|
||||
protocolVersion: CURRENT_HUB_PROTOCOL_VERSION,
|
||||
minClientProtocolVersion: MIN_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
maxClientProtocolVersion: MAX_CLIENT_HUB_PROTOCOL_VERSION,
|
||||
capabilities: [...versionPayload.capabilities],
|
||||
coreVersion: corePackage.version,
|
||||
buildId,
|
||||
authToken,
|
||||
@@ -511,9 +572,12 @@ export async function ensureHubWebSocketServer(
|
||||
discovered?.url &&
|
||||
(discovered.url === expectedUrl || options.allowPortFallback === true);
|
||||
if (canReuseDiscovered) {
|
||||
const healthy = await probeHubServer(discovered.url);
|
||||
const healthy = await probeHubServer(discovered.url, {
|
||||
authToken: discovered.authToken,
|
||||
});
|
||||
if (
|
||||
healthy?.url &&
|
||||
isHubProtocolCompatible(healthy).compatible &&
|
||||
(await verifyHubConnection(healthy.url, {
|
||||
authToken: discovered.authToken,
|
||||
}))
|
||||
@@ -526,8 +590,9 @@ export async function ensureHubWebSocketServer(
|
||||
}
|
||||
}
|
||||
|
||||
const expected = await probeHubServer(expectedUrl);
|
||||
if (expected?.url || discovered?.url) {
|
||||
// The discovered hub was not reusable (missing, mismatched, or failed
|
||||
// verification), so its record is stale either way.
|
||||
if (discovered?.url) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export type {
|
||||
ListProvidersActionRequest,
|
||||
Message,
|
||||
MessageWithMetadata,
|
||||
OAuthProviderId,
|
||||
ProviderActionRequest,
|
||||
ProviderCatalogResponse,
|
||||
ProviderListItem,
|
||||
@@ -117,7 +116,6 @@ export {
|
||||
} from "./auth/client";
|
||||
export {
|
||||
completeClineDeviceAuth,
|
||||
createClineOAuthProvider,
|
||||
getValidClineCredentials,
|
||||
loginClineOAuth,
|
||||
refreshClineToken,
|
||||
@@ -125,20 +123,30 @@ export {
|
||||
} from "./auth/cline";
|
||||
export {
|
||||
getValidOpenAICodexCredentials,
|
||||
isOpenAICodexTokenExpired,
|
||||
loginOpenAICodex,
|
||||
normalizeOpenAICodexCredentials,
|
||||
openaiCodexOAuthProvider,
|
||||
refreshOpenAICodexToken,
|
||||
} from "./auth/codex";
|
||||
export {
|
||||
createOcaOAuthProvider,
|
||||
createOcaRequestHeaders,
|
||||
generateOcaOpcRequestId,
|
||||
getValidOcaCredentials,
|
||||
loginOcaOAuth,
|
||||
refreshOcaToken,
|
||||
} from "./auth/oca";
|
||||
export {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderAuthHandler,
|
||||
getProviderAuthStorageId,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
isOAuthProvider,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderAuthHandler,
|
||||
type ProviderAuthLoginInput,
|
||||
type ProviderAuthRefreshInput,
|
||||
type ProviderAuthSaveCredentialsInput,
|
||||
type ProviderOAuthCredentials,
|
||||
resolveProviderApiKeyFromSettings,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "./auth/provider-auth-registry";
|
||||
export type {
|
||||
LocalOAuthServer,
|
||||
LocalOAuthServerOptions,
|
||||
@@ -290,10 +298,19 @@ export {
|
||||
type BootstrapAgentTeamsOptions,
|
||||
type BootstrapAgentTeamsResult,
|
||||
bootstrapAgentTeams,
|
||||
buildConfiguredAgentToolDescriptors,
|
||||
buildConfiguredAgentToolName,
|
||||
buildDelegatedAgentConfig,
|
||||
buildTeamProgressSummary,
|
||||
type ConfiguredAgentConfig,
|
||||
type ConfiguredAgentInput,
|
||||
type ConfiguredAgentLoadResult,
|
||||
type ConfiguredAgentReadError,
|
||||
type ConfiguredAgentToolConfig,
|
||||
type ConfiguredAgentToolDescriptor,
|
||||
type CreateAgentTeamsToolsOptions,
|
||||
createAgentTeamsTools,
|
||||
createConfiguredAgentTools,
|
||||
createDelegatedAgent,
|
||||
createDelegatedAgentConfigProvider,
|
||||
createSpawnAgentTool,
|
||||
@@ -301,6 +318,8 @@ export {
|
||||
type DelegatedAgentConnectionConfig,
|
||||
type DelegatedAgentKind,
|
||||
type DelegatedAgentRuntimeConfig,
|
||||
loadConfiguredAgentConfigs,
|
||||
parseConfiguredAgentConfig,
|
||||
reviveTeamStateDates,
|
||||
type SpawnTeammateOptions,
|
||||
type SubAgentEndContext,
|
||||
@@ -455,6 +474,7 @@ export {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
refreshProviderModelsFromSource,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
normalizeUserInput,
|
||||
} from "@cline/shared";
|
||||
import { setHomeDirIfUnset } from "@cline/shared/storage";
|
||||
import { isOAuthProvider } from "../../auth/provider-auth-registry";
|
||||
import { createContextCompactionPrepareTurn } from "../../extensions/context/compaction";
|
||||
import type { ToolExecutors } from "../../extensions/tools";
|
||||
import { DefaultToolNames } from "../../extensions/tools";
|
||||
@@ -95,6 +96,7 @@ import {
|
||||
} from "./local/session-service-invoker";
|
||||
import {
|
||||
createSessionSpawnTool,
|
||||
createSessionSubAgentLifecycleCallbacks,
|
||||
type SubAgentStartTracker,
|
||||
} from "./local/spawn-tool";
|
||||
import { loadUserFileContent } from "./local/user-files";
|
||||
@@ -358,6 +360,17 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
const pluginEventFallbackAutomation =
|
||||
inputLocalConfig?.extensionContext?.automation;
|
||||
let bootstrap!: Awaited<ReturnType<typeof prepareLocalRuntimeBootstrap>>;
|
||||
const subAgentDeps = {
|
||||
getSession: (sid: string) => this.sessions.get(sid),
|
||||
subAgentStarts: this.subAgentStarts,
|
||||
onAgentEvent: (
|
||||
rootSessionId: string,
|
||||
config: CoreSessionConfig,
|
||||
event: AgentEvent,
|
||||
) => this.eventBridge.dispatchAgentEvent(rootSessionId, config, event),
|
||||
invokeBackendOptional: (method: string, ...args: unknown[]) =>
|
||||
this.invokeOptional(method, ...args),
|
||||
};
|
||||
bootstrap = await prepareLocalRuntimeBootstrap({
|
||||
input: startInput,
|
||||
localRuntime: input.localRuntime,
|
||||
@@ -388,18 +401,17 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
},
|
||||
createSpawnTool: () =>
|
||||
createSessionSpawnTool(
|
||||
{
|
||||
getSession: (sid) => this.sessions.get(sid),
|
||||
subAgentStarts: this.subAgentStarts,
|
||||
onAgentEvent: (rootSessionId, config, event) =>
|
||||
this.eventBridge.dispatchAgentEvent(rootSessionId, config, event),
|
||||
invokeBackendOptional: (method, ...args) =>
|
||||
this.invokeOptional(method, ...args),
|
||||
},
|
||||
subAgentDeps,
|
||||
bootstrap.config,
|
||||
sessionId,
|
||||
sessionToolExecutors,
|
||||
),
|
||||
createSubAgentLifecycleCallbacks: (config) =>
|
||||
createSessionSubAgentLifecycleCallbacks(
|
||||
subAgentDeps,
|
||||
config,
|
||||
sessionId,
|
||||
),
|
||||
readSessionMetadata: async () =>
|
||||
(await this.getSession(sessionId))?.metadata as
|
||||
| Record<string, unknown>
|
||||
@@ -1533,7 +1545,10 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!isLikelyAuthError(error, session.config.providerId)) {
|
||||
if (
|
||||
!isOAuthProvider(session.config.providerId) ||
|
||||
!isLikelyAuthError(error)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
await this.syncOAuthCredentials(session, { forceRefresh: true });
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
type ToolExecutors,
|
||||
ToolPresets,
|
||||
} from "../../../extensions/tools";
|
||||
import type {
|
||||
SubAgentEndContext,
|
||||
SubAgentStartContext,
|
||||
} from "../../../extensions/tools/team";
|
||||
import { createSpawnAgentTool } from "../../../extensions/tools/team";
|
||||
import { buildTelemetryAgentIdentity } from "../../../services/agent-events";
|
||||
import { filterDisabledTools } from "../../../services/global-settings";
|
||||
@@ -31,65 +35,18 @@ export interface SpawnToolDeps {
|
||||
invokeBackendOptional(method: string, ...args: unknown[]): Promise<void>;
|
||||
}
|
||||
|
||||
export function createSessionSpawnTool(
|
||||
export interface SessionSubAgentLifecycleCallbacks {
|
||||
onSubAgentEvent: (event: AgentEvent) => void;
|
||||
onSubAgentStart: (context: SubAgentStartContext) => void;
|
||||
onSubAgentEnd: (context: SubAgentEndContext) => void;
|
||||
}
|
||||
|
||||
export function createSessionSubAgentLifecycleCallbacks(
|
||||
deps: SpawnToolDeps,
|
||||
config: CoreSessionConfig,
|
||||
rootSessionId: string,
|
||||
toolExecutors?: Partial<ToolExecutors>,
|
||||
): AgentTool {
|
||||
const createSubAgentTools = () => {
|
||||
const tools: AgentTool[] = config.enableTools
|
||||
? createBuiltinTools({
|
||||
cwd: config.cwd,
|
||||
...ToolPresets[resolveToolPresetName({ mode: config.mode })],
|
||||
executors: toolExecutors,
|
||||
})
|
||||
: [];
|
||||
if (config.enableSpawnAgent) {
|
||||
tools.push(
|
||||
createSessionSpawnTool(deps, config, rootSessionId, toolExecutors),
|
||||
);
|
||||
}
|
||||
return filterDisabledTools(tools);
|
||||
};
|
||||
|
||||
return createSpawnAgentTool({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getRuntimeConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
cwd: config.cwd,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
maxIterations: config.maxIterations,
|
||||
hooks: config.hooks,
|
||||
extensions: config.extensions,
|
||||
logger: config.logger,
|
||||
telemetry: config.telemetry,
|
||||
},
|
||||
getConnectionConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getConnectionConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
},
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
createSubAgentTools,
|
||||
): SessionSubAgentLifecycleCallbacks {
|
||||
return {
|
||||
onSubAgentEvent: (event) => deps.onAgentEvent(rootSessionId, config, event),
|
||||
onSubAgentStart: (context) => {
|
||||
const teamRuntime = deps.getSession(rootSessionId)?.runtime.teamRuntime;
|
||||
@@ -158,5 +115,73 @@ export function createSessionSpawnTool(
|
||||
context,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionSpawnTool(
|
||||
deps: SpawnToolDeps,
|
||||
config: CoreSessionConfig,
|
||||
rootSessionId: string,
|
||||
toolExecutors?: Partial<ToolExecutors>,
|
||||
): AgentTool {
|
||||
const lifecycle = createSessionSubAgentLifecycleCallbacks(
|
||||
deps,
|
||||
config,
|
||||
rootSessionId,
|
||||
);
|
||||
const createSubAgentTools = () => {
|
||||
const tools: AgentTool[] = config.enableTools
|
||||
? createBuiltinTools({
|
||||
cwd: config.cwd,
|
||||
...ToolPresets[resolveToolPresetName({ mode: config.mode })],
|
||||
executors: toolExecutors,
|
||||
})
|
||||
: [];
|
||||
if (config.enableSpawnAgent) {
|
||||
tools.push(
|
||||
createSessionSpawnTool(deps, config, rootSessionId, toolExecutors),
|
||||
);
|
||||
}
|
||||
return filterDisabledTools(tools);
|
||||
};
|
||||
|
||||
return createSpawnAgentTool({
|
||||
configProvider: {
|
||||
getRuntimeConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getRuntimeConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
cwd: config.cwd,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
maxIterations: config.maxIterations,
|
||||
hooks: config.hooks,
|
||||
extensions: config.extensions,
|
||||
logger: config.logger,
|
||||
telemetry: config.telemetry,
|
||||
},
|
||||
getConnectionConfig: () =>
|
||||
deps
|
||||
.getSession(rootSessionId)
|
||||
?.runtime.delegatedAgentConfigProvider?.getConnectionConfig() ?? {
|
||||
providerId: config.providerId,
|
||||
modelId: config.modelId,
|
||||
apiKey: config.apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
headers: config.headers,
|
||||
providerConfig: config.providerConfig,
|
||||
knownModels: config.knownModels,
|
||||
thinking: config.thinking,
|
||||
},
|
||||
updateConnectionDefaults: () => {},
|
||||
},
|
||||
createSubAgentTools,
|
||||
...lifecycle,
|
||||
}) as AgentTool;
|
||||
}
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type AgentConfig,
|
||||
type AgentEvent,
|
||||
type AgentExtension,
|
||||
type AgentTool,
|
||||
createContributionRegistry,
|
||||
type Message,
|
||||
} from "@cline/shared";
|
||||
import { setHomeDir } from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UserInstructionConfigService } from "../../extensions/config";
|
||||
import type { CoreSessionConfig } from "../../types/config";
|
||||
|
||||
const runMock = vi.fn();
|
||||
const agentConstructorSpy = vi.fn();
|
||||
let eventListeners: Array<(event: AgentEvent) => void> = [];
|
||||
|
||||
vi.mock("./session-runtime-orchestrator", () => {
|
||||
return {
|
||||
SessionRuntime: class MockSessionRuntime {
|
||||
constructor(config: unknown) {
|
||||
agentConstructorSpy(config);
|
||||
}
|
||||
|
||||
getAgentId(): string {
|
||||
return "configured-sub-agent";
|
||||
}
|
||||
|
||||
getConversationId(): string {
|
||||
return "configured-sub-conversation";
|
||||
}
|
||||
|
||||
subscribeEvents(listener: (event: AgentEvent) => void): () => void {
|
||||
eventListeners.push(listener);
|
||||
return () => {
|
||||
eventListeners = eventListeners.filter((entry) => entry !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
async run(input: string): Promise<unknown> {
|
||||
for (const listener of eventListeners) {
|
||||
listener({
|
||||
type: "notice",
|
||||
noticeType: "status",
|
||||
message: "configured agent running",
|
||||
});
|
||||
}
|
||||
return runMock(input);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function makeBaseConfig(
|
||||
overrides: Partial<CoreSessionConfig> = {},
|
||||
): CoreSessionConfig {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: "key",
|
||||
systemPrompt: "test",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectExtensionTools(
|
||||
extensions?: AgentExtension[],
|
||||
): Promise<AgentTool[]> {
|
||||
const registry = createContributionRegistry<
|
||||
AgentExtension,
|
||||
AgentTool,
|
||||
Message[]
|
||||
>({
|
||||
extensions: extensions ?? [],
|
||||
});
|
||||
await registry.initialize();
|
||||
return registry.getRegisteredTools();
|
||||
}
|
||||
|
||||
describe("DefaultRuntimeBuilder configured agent execution", () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventListeners = [];
|
||||
runMock.mockResolvedValue({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
setHomeDir(previousHome ?? "~");
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("invokes configured agents with host callbacks, scoped tools, skills, overrides, and parent context", async () => {
|
||||
const { DefaultRuntimeBuilder } = await import("./runtime-builder");
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-root-"));
|
||||
const cwd = join(workspaceRoot, "packages", "app");
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
process.env.HOME = tempHome;
|
||||
setHomeDir(tempHome);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "commit");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
tools: Execute_Command, Read_File, Use_Skill
|
||||
skills: commit
|
||||
providerId: openai
|
||||
modelId: gpt-4.1
|
||||
maxIterations: 3
|
||||
---
|
||||
You are a reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: commit
|
||||
description: Commit messages
|
||||
---
|
||||
Write a concise commit message.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const requestToolApproval = vi.fn(async () => ({ approved: true }));
|
||||
const onSubAgentEvent = vi.fn();
|
||||
const onSubAgentStart = vi.fn();
|
||||
const onSubAgentEnd = vi.fn();
|
||||
const effectiveToolPolicies = {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { enabled: false },
|
||||
};
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({ cwd, workspaceRoot }),
|
||||
configExtensions: [],
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
requestToolApproval,
|
||||
onSubAgentEvent,
|
||||
onSubAgentStart,
|
||||
onSubAgentEnd,
|
||||
});
|
||||
|
||||
expect(
|
||||
(await collectExtensionTools(runtime.extensions)).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("skills");
|
||||
|
||||
const reviewer = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_reviewer",
|
||||
);
|
||||
expect(reviewer).toBeDefined();
|
||||
if (!reviewer) {
|
||||
throw new Error("Expected configured reviewer tool.");
|
||||
}
|
||||
|
||||
const output = await reviewer.execute(
|
||||
{ prompt: "review this change" },
|
||||
{
|
||||
agentId: "parent-agent",
|
||||
conversationId: "parent-conversation",
|
||||
iteration: 1,
|
||||
},
|
||||
);
|
||||
|
||||
expect(output).toEqual({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
expect(runMock).toHaveBeenCalledWith("review this change");
|
||||
expect(onSubAgentStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subAgentId: "configured-sub-agent",
|
||||
conversationId: "configured-sub-conversation",
|
||||
parentAgentId: "parent-agent",
|
||||
input: {
|
||||
systemPrompt: "You are a reviewer.",
|
||||
task: "review this change",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(onSubAgentEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "notice",
|
||||
message: "configured agent running",
|
||||
}),
|
||||
);
|
||||
expect(onSubAgentEnd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parentAgentId: "parent-agent",
|
||||
result: output,
|
||||
}),
|
||||
);
|
||||
|
||||
const delegatedConfig = agentConstructorSpy.mock.calls.at(-1)?.[0] as
|
||||
| AgentConfig
|
||||
| undefined;
|
||||
expect(delegatedConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4.1",
|
||||
maxIterations: 3,
|
||||
parentAgentId: "parent-agent",
|
||||
requestToolApproval,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
}),
|
||||
);
|
||||
expect(delegatedConfig?.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"run_commands",
|
||||
"skills",
|
||||
]);
|
||||
|
||||
const skillsTool = delegatedConfig?.tools.find(
|
||||
(tool) => tool.name === "skills",
|
||||
);
|
||||
expect(skillsTool).toBeDefined();
|
||||
if (!skillsTool) {
|
||||
throw new Error("Expected delegated skills tool.");
|
||||
}
|
||||
await expect(
|
||||
skillsTool.execute(
|
||||
{ skill: "commit" },
|
||||
{ agentId: "configured-sub-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toContain("<command-name>commit</command-name>");
|
||||
await expect(
|
||||
skillsTool.execute(
|
||||
{ skill: "review" },
|
||||
{ agentId: "configured-sub-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toContain('Skill "review" not found.');
|
||||
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
|
||||
it("does not require custom user instruction services to implement createSkillsExecutor", async () => {
|
||||
const { DefaultRuntimeBuilder } = await import("./runtime-builder");
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-compat-"));
|
||||
tempDirs.push(workspaceRoot);
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "reviewer.yml"),
|
||||
`---
|
||||
name: reviewer
|
||||
description: Reviews code
|
||||
skills: commit
|
||||
---
|
||||
You are a reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const legacyService = {
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
refreshType: vi.fn(async () => {}),
|
||||
listRecords: vi.fn(() => []),
|
||||
listRuntimeCommands: vi.fn(() => []),
|
||||
resolveRuntimeSlashCommand: vi.fn((input: string) => input),
|
||||
hasConfiguredSkills: vi.fn(() => false),
|
||||
createExtension: vi.fn(() => ({
|
||||
name: "legacy-service",
|
||||
manifest: { capabilities: [] },
|
||||
})),
|
||||
} as unknown as UserInstructionConfigService;
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({ cwd: workspaceRoot, workspaceRoot }),
|
||||
configExtensions: [],
|
||||
userInstructionService: legacyService,
|
||||
});
|
||||
const reviewer = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_reviewer",
|
||||
);
|
||||
expect(reviewer).toBeDefined();
|
||||
if (!reviewer) {
|
||||
throw new Error("Expected configured reviewer tool.");
|
||||
}
|
||||
|
||||
await expect(
|
||||
reviewer.execute(
|
||||
{ prompt: "review this change" },
|
||||
{ agentId: "parent-agent", iteration: 1 },
|
||||
),
|
||||
).resolves.toEqual({
|
||||
text: "configured result",
|
||||
iterations: 2,
|
||||
finishReason: "completed",
|
||||
usage: { inputTokens: 13, outputTokens: 8 },
|
||||
});
|
||||
const delegatedConfig = agentConstructorSpy.mock.calls.at(-1)?.[0] as
|
||||
| AgentConfig
|
||||
| undefined;
|
||||
expect(delegatedConfig?.tools.map((tool) => tool.name)).not.toContain(
|
||||
"skills",
|
||||
);
|
||||
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -56,11 +56,15 @@ async function collectExtensionTools(
|
||||
describe("DefaultRuntimeBuilder", () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const previousGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = previousHome;
|
||||
setHomeDir(previousHome ?? "~");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = previousGlobalSettingsPath;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes builtin tools when enabled", async () => {
|
||||
@@ -88,6 +92,100 @@ describe("DefaultRuntimeBuilder", () => {
|
||||
expect(runtime.logger).toBe(logger);
|
||||
});
|
||||
|
||||
it("loads configured agent files as named subagent tools", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-workspace-"));
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
setHomeDir(tempHome);
|
||||
|
||||
const globalAgentsDir = join(tempHome, ".cline", "agents");
|
||||
mkdirSync(globalAgentsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(globalAgentsDir, "code-reviewer.yml"),
|
||||
`---
|
||||
name: code-reviewer
|
||||
description: Reviews code for quality and best practices
|
||||
tools: Execute_Command, Read_File
|
||||
modelId: anthropic/claude-sonnet-4.6
|
||||
---
|
||||
You are a code reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
}),
|
||||
createSpawnTool: makeSpawnTool,
|
||||
});
|
||||
|
||||
const configuredAgentTool = runtime.tools.find(
|
||||
(tool) => tool.name === "subagent_code_reviewer",
|
||||
);
|
||||
expect(configuredAgentTool).toBeDefined();
|
||||
expect(configuredAgentTool?.description).toContain(
|
||||
'Use the "code-reviewer" subagent',
|
||||
);
|
||||
expect(runtime.tools.map((tool) => tool.name)).toContain("spawn_agent");
|
||||
});
|
||||
|
||||
it("does not register root skills when only configured agents declare skills", async () => {
|
||||
const tempHome = mkdtempSync(join(tmpdir(), "cline-agent-home-"));
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-agent-workspace-"));
|
||||
const cwd = join(workspaceRoot, "packages", "app");
|
||||
tempDirs.push(tempHome, workspaceRoot);
|
||||
setHomeDir(tempHome);
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const agentsDir = join(workspaceRoot, ".cline", "agents");
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(agentsDir, { recursive: true });
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(agentsDir, "code-reviewer.yml"),
|
||||
`---
|
||||
name: code-reviewer
|
||||
description: Reviews code
|
||||
tools: use_skill
|
||||
skills: review
|
||||
---
|
||||
You are a code reviewer.`,
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: review
|
||||
---
|
||||
Use the review guidance.`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
enableSpawnAgent: true,
|
||||
}),
|
||||
configExtensions: [],
|
||||
createSpawnTool: makeSpawnTool,
|
||||
});
|
||||
|
||||
expect(runtime.tools.map((tool) => tool.name)).toContain(
|
||||
"subagent_code_reviewer",
|
||||
);
|
||||
expect(runtime.tools.map((tool) => tool.name)).not.toContain("skills");
|
||||
expect(
|
||||
(await collectExtensionTools(runtime.extensions)).map(
|
||||
(tool) => tool.name,
|
||||
),
|
||||
).not.toContain("skills");
|
||||
await runtime.shutdown("test");
|
||||
});
|
||||
|
||||
it("forwards telemetry for downstream runtime consumers", async () => {
|
||||
const telemetry = new TelemetryService();
|
||||
const runtime = await new DefaultRuntimeBuilder().build({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import {
|
||||
createBuiltinTools,
|
||||
DEFAULT_MODEL_TOOL_ROUTING_RULES,
|
||||
DefaultToolNames,
|
||||
resolveToolPresetName,
|
||||
resolveToolRoutingConfig,
|
||||
type SkillsExecutorWithMetadata,
|
||||
@@ -32,6 +31,9 @@ import {
|
||||
createDelegatedAgentConfigProvider,
|
||||
type TeamEvent,
|
||||
} from "../../extensions/tools/team";
|
||||
import type { ConfiguredAgentConfig } from "../../extensions/tools/team/configured-agent-config";
|
||||
import { loadConfiguredAgentConfigs } from "../../extensions/tools/team/configured-agent-config";
|
||||
import { createConfiguredAgentTools } from "../../extensions/tools/team/configured-agent-tool";
|
||||
import {
|
||||
filterDisabledTools,
|
||||
resolveDisabledToolNames,
|
||||
@@ -81,6 +83,42 @@ function filterAvailableTools(
|
||||
return filterDisabledTools(filterToolsByPolicies(tools, toolPolicies));
|
||||
}
|
||||
|
||||
const CONFIGURED_AGENT_TOOL_NAME_ALIASES: Record<string, string> = {
|
||||
apply_diff: "editor",
|
||||
attempt_completion: "submit_and_exit",
|
||||
bash: "run_commands",
|
||||
execute_command: "run_commands",
|
||||
list_code_definition_names: "search_codebase",
|
||||
list_files: "run_commands",
|
||||
read_file: "read_files",
|
||||
replace_in_file: "editor",
|
||||
search_files: "search_codebase",
|
||||
use_skill: "skills",
|
||||
write_to_file: "editor",
|
||||
};
|
||||
|
||||
function resolveConfiguredAgentToolName(toolName: string): string {
|
||||
const normalized = toolName.trim().toLowerCase();
|
||||
return CONFIGURED_AGENT_TOOL_NAME_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function filterToolsForConfiguredAgent(
|
||||
tools: AgentTool[],
|
||||
agent: ConfiguredAgentConfig,
|
||||
): AgentTool[] {
|
||||
if (agent.tools === undefined) {
|
||||
return tools;
|
||||
}
|
||||
|
||||
const allowedToolNames = new Set(
|
||||
agent.tools.map(resolveConfiguredAgentToolName),
|
||||
);
|
||||
if (agent.skills !== undefined) {
|
||||
allowedToolNames.add("skills");
|
||||
}
|
||||
return tools.filter((tool) => allowedToolNames.has(tool.name));
|
||||
}
|
||||
|
||||
export function createTeamName(): string {
|
||||
return `team-${nanoid(5)}`;
|
||||
}
|
||||
@@ -310,16 +348,28 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
} = input;
|
||||
const onTeamEvent = input.onTeamEvent ?? (() => {});
|
||||
const normalized = normalizeConfig(config);
|
||||
const workspaceConfigRoot = config.workspaceRoot ?? config.cwd;
|
||||
const effectiveToolPolicies = input.toolPolicies ?? config.toolPolicies;
|
||||
const globallyDisabledToolNames = resolveDisabledToolNames();
|
||||
const tools: AgentTool[] = [];
|
||||
const effectiveTeamName = config.teamName?.trim() || createTeamName();
|
||||
const teamStoreKey = config.sessionId?.trim() || effectiveTeamName;
|
||||
const configuredAgents = normalized.enableSpawnAgent
|
||||
? loadConfiguredAgentConfigs({
|
||||
workspaceRoot: workspaceConfigRoot,
|
||||
})
|
||||
: { configs: [], errors: [] };
|
||||
const configuredAgentsNeedSkills = configuredAgents.configs.some(
|
||||
(agent) => agent.skills !== undefined,
|
||||
);
|
||||
const rulesEnabled = hasConfigExtension(configExtensions, "rules");
|
||||
const skillsEnabled = hasConfigExtension(configExtensions, "skills");
|
||||
const rootSkillsEnabled = hasConfigExtension(configExtensions, "skills");
|
||||
const needsSkillsConfigService =
|
||||
rootSkillsEnabled || configuredAgentsNeedSkills;
|
||||
const workflowsEnabled = hasConfigExtension(configExtensions, "workflows");
|
||||
const pluginsEnabled = hasConfigExtension(configExtensions, "plugins");
|
||||
const userInstructionsEnabled =
|
||||
rulesEnabled || skillsEnabled || workflowsEnabled;
|
||||
rulesEnabled || rootSkillsEnabled || workflowsEnabled;
|
||||
let teamToolsRegistered = false;
|
||||
const userInstructionServiceProvided = Boolean(
|
||||
sharedUserInstructionService,
|
||||
@@ -327,11 +377,20 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
let userInstructionService = sharedUserInstructionService;
|
||||
let mcpShutdown: (() => Promise<void>) | undefined;
|
||||
|
||||
if (!userInstructionService && userInstructionsEnabled) {
|
||||
for (const error of configuredAgents.errors) {
|
||||
(logger ?? config.logger)?.log?.(
|
||||
`[agents] Failed to load agent config at ${error.path}: ${error.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!userInstructionService &&
|
||||
(userInstructionsEnabled || configuredAgentsNeedSkills)
|
||||
) {
|
||||
userInstructionService = createUserInstructionConfigService({
|
||||
skills: skillsEnabled
|
||||
skills: needsSkillsConfigService
|
||||
? {
|
||||
workspacePath: config.cwd,
|
||||
workspacePath: workspaceConfigRoot,
|
||||
includePluginSkills: pluginsEnabled,
|
||||
pluginSkillDirectories: pluginsEnabled
|
||||
? input.pluginSkillDirectories
|
||||
@@ -339,7 +398,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
pluginPaths: config.pluginPaths,
|
||||
cwd: config.cwd,
|
||||
}
|
||||
: { workspacePath: config.cwd },
|
||||
: { workspacePath: workspaceConfigRoot },
|
||||
rules: { workspacePath: config.cwd },
|
||||
workflows: { workspacePath: config.cwd },
|
||||
});
|
||||
@@ -351,7 +410,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
|
||||
const registerSkillsTool =
|
||||
normalized.enableTools &&
|
||||
skillsEnabled &&
|
||||
rootSkillsEnabled &&
|
||||
Boolean(userInstructionService) &&
|
||||
userInstructionService?.hasConfiguredSkills(config.skills) === true &&
|
||||
isSkillsToolEnabledForSession({
|
||||
@@ -360,7 +419,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
mode: normalized.mode,
|
||||
modelId: config.modelId,
|
||||
toolRoutingRules: config.toolRoutingRules,
|
||||
toolPolicies: config.toolPolicies,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
toolExecutors,
|
||||
});
|
||||
|
||||
@@ -368,7 +427,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
userInstructionService && userInstructionsEnabled
|
||||
? userInstructionService.createExtension({
|
||||
includeRules: rulesEnabled,
|
||||
includeSkills: skillsEnabled,
|
||||
includeSkills: rootSkillsEnabled,
|
||||
includeWorkflows: workflowsEnabled,
|
||||
registerSkillsTool,
|
||||
allowedSkillNames: config.skills,
|
||||
@@ -386,7 +445,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
normalized.mode,
|
||||
config.modelId,
|
||||
config.toolRoutingRules,
|
||||
config.toolPolicies,
|
||||
effectiveToolPolicies,
|
||||
undefined,
|
||||
toolExecutors,
|
||||
),
|
||||
@@ -433,6 +492,46 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
telemetry: input.telemetry ?? config.telemetry,
|
||||
workspaceMetadata: config.workspaceMetadata,
|
||||
});
|
||||
if (normalized.enableSpawnAgent) {
|
||||
if (configuredAgents.configs.length > 0) {
|
||||
tools.push(
|
||||
...filterAvailableTools(
|
||||
createConfiguredAgentTools({
|
||||
configProvider: delegatedAgentConfigProvider,
|
||||
agents: configuredAgents.configs,
|
||||
createSubAgentTools: (agent) =>
|
||||
normalized.enableTools
|
||||
? filterToolsForConfiguredAgent(
|
||||
createBuiltinToolsList(
|
||||
config.cwd,
|
||||
agent.providerId ?? config.providerId,
|
||||
normalized.mode,
|
||||
agent.modelId ?? config.modelId,
|
||||
config.toolRoutingRules,
|
||||
effectiveToolPolicies,
|
||||
agent.skills !== undefined &&
|
||||
userInstructionService?.createSkillsExecutor
|
||||
? userInstructionService.createSkillsExecutor(
|
||||
agent.skills,
|
||||
)
|
||||
: undefined,
|
||||
toolExecutors,
|
||||
),
|
||||
agent,
|
||||
)
|
||||
: [],
|
||||
hookErrorMode: config.hookErrorMode,
|
||||
toolPolicies: effectiveToolPolicies,
|
||||
requestToolApproval: input.requestToolApproval,
|
||||
onSubAgentEvent: input.onSubAgentEvent,
|
||||
onSubAgentStart: input.onSubAgentStart,
|
||||
onSubAgentEnd: input.onSubAgentEnd,
|
||||
}),
|
||||
effectiveToolPolicies,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.teamRuntimeEntries.has(registryKey)) {
|
||||
this.teamRuntimeEntries.set(registryKey, {
|
||||
delegatedAgentConfigProvider,
|
||||
@@ -518,7 +617,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
normalized.mode,
|
||||
config.modelId,
|
||||
config.toolRoutingRules,
|
||||
config.toolPolicies,
|
||||
effectiveToolPolicies,
|
||||
undefined,
|
||||
toolExecutors,
|
||||
)
|
||||
@@ -554,10 +653,10 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
ensureTeamRuntime();
|
||||
}
|
||||
|
||||
const finalTools = filterAvailableTools(tools, config.toolPolicies);
|
||||
const finalTools = filterAvailableTools(tools, effectiveToolPolicies);
|
||||
const requiresCompletionTool = finalTools.some(
|
||||
(tool) =>
|
||||
tool.name === DefaultToolNames.SUBMIT_AND_EXIT &&
|
||||
tool.name === "submit_and_exit" &&
|
||||
tool.lifecycle?.completesRun === true,
|
||||
);
|
||||
const teamCompletionGuard = normalized.enableAgentTeams
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user